fix: aide (bouton ✕), Insert below qui perdait le format, listes numérotées invisibles (v7.59.2)
FlowDeck CI / lint (push) Successful in 2m16s
FlowDeck CI / test (push) Failing after 18m25s
FlowDeck CI / docker (push) Skipped

- 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
This commit is contained in:
2026-10-07 20:29:02 -04:00
parent 39d34ac866
commit a67bd252aa
11 changed files with 181 additions and 16 deletions
+29
View File
@@ -1,5 +1,34 @@
# Changelog - FlowDeck
## v7.59.2 (2026-10-07) — Corrections : aide, « Insert below », listes numérotées
### Fixed
- **Aide** — bouton **✕** en haut à droite du panneau (classe partagée
`settings-close` : 32 px desktop / **44 px mobile**) + méthode `close()` dans
`help.js` (retour à la page précédente, sinon `/workspaces`) ;
`.settings-panel` passé en `position:relative` pour ancrer les boutons au
panneau (hamburger + ✕) plutôt qu'à la page.
- **Éditeur — « Insert below » (/Write with AI)** : on insère le **markdown
source** conservé à la génération (`AIC._md`) au lieu du texte extrait du
HTML rendu (`_resultText()` ne reste qu'en repli pour les messages d'erreur) —
titres, listes, gras et numéros survivent à l'insertion.
- **Listes numérotées** — elles n'avaient **aucun numéro au rendu** : le CSS
ne déclarait que le retrait, jamais le marqueur. Le rang des blocs
`numbered_list` contigus est désormais calculé au rendu (`data-num`) et
affiché via `content: attr(data-num) ". "` → **`/numbered list` et
l'insertion depuis l'assistant affichent enfin 1, 2, 3**.
- **`md2b`** (markdown → blocs, chemin agent) : les cases à cocher `- [ ]` /
`- [x]` sont testées **avant** la branche des puces (une ligne `- [ ]`
était avalée par cette branche) — aligné sur l'ordre déjà correct de
`paste2b` ; le séparateur `1)` est accepté (CommonMark).
- **Menu slash** — une requête tapée **dans le bloc** (`/numbered list`, focus
qui n'a pas rejoint l'entrée du menu) est répercutée dans le filtre au lieu
d'être ignorée.
- **Tests** — `tests/test_v7592_ui_fixes.py` : **5 tests**, dont 1 test
comportemental **node** qui exécute le vrai `md2b` extrait de la source.
- `ruff` : ordre d'imports de `tests/test_agent.py` (I001 préexistant au pull).
## v7.59.1 (2026-10-07) — Fix fournisseur Mistral (clé valide, 403 tier_not_allowed)
### Fixed
+1 -1
View File
@@ -1 +1 @@
7.59.1
7.59.2
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.59.1",
version="7.59.2",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+1
View File
@@ -35,6 +35,7 @@
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<button class="settings-close" @click="close()" title="Close" aria-label="Close help">×</button>
<!-- Mobile drawer backdrop -->
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
+2 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.58.0"
"version": "7.59.2"
},
"paths": {
"/auth/register": {
@@ -1098,7 +1098,7 @@
"dashboard"
],
"summary": "Help Page",
"description": "Comprehensive help & documentation page.",
"description": "Help & documentation page — settings-style panel with side navigation.",
"operationId": "help_page_help_get",
"responses": {
"200": {
+11 -2
View File
@@ -2476,11 +2476,20 @@ button.breadcrumb-current { max-width: 260px; }
font-size: 16px;
}
/* Numbered list */
/* Numbered list — data-num est calculé au rendu (rangée contiguë) */
.block-numbered {
padding-left: 22px;
padding-left: 32px;
position: relative;
}
.block-numbered[data-num]::before {
content: attr(data-num) ". ";
position: absolute;
left: 0;
top: 0;
min-width: 24px;
color: var(--text-primary);
font-size: 16px;
}
/* To-do */
.block-todo {
+1 -1
View File
@@ -1,6 +1,6 @@
/* ═══ Panel skeleton shared by /settings and /help (Notion-style modal) ═══ */
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-panel{position:relative;display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
+5
View File
@@ -7,6 +7,11 @@ function helpInit() {
section: 'start',
navOpen: false,
closeNavOnMobile() { this.navOpen = false; },
// Bouton ✕ (haut droite, visible aussi en mobile) : ferme l'aide.
close() {
if (document.referrer && document.referrer !== location.href) { history.back(); }
else { location.href = '/workspaces'; }
},
go(sec) {
this.section = sec;
this.navOpen = false;
+29 -8
View File
@@ -419,7 +419,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
// → framed result → "Insert below" → applyAIBlocks.
const AIC={_open:false,_idx:-1,_stage:'',_html:'',
const AIC={_open:false,_idx:-1,_stage:'',_html:'',_md:'',
ROOT:null,
mk(){ if(this.ROOT) return this.ROOT;
const d=document.createElement('div'); d.id='_aiC'; d.style.cssText='position:fixed;z-index:1200;width:420px;max-width:92vw;display:none';
@@ -432,7 +432,7 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
this.paint(); this.ROOT.style.display='block'; this._open=true; this._focusIngest();
document.addEventListener('mousedown',function onDoc(ev){ if(!self.ROOT.contains(ev.target)){ self.close(); document.removeEventListener('mousedown',onDoc); } });
},
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; if(window.E)window.E.dirty=true; this._stage=''; },
close(){ if(this.ROOT)this.ROOT.style.display='none'; this._open=false; this._html=''; this._md=''; if(window.E)window.E.dirty=true; this._stage=''; },
_anchorRect(idx){ const E=window.E; if(!E||!E.blocks)return{left:Math.max(24,window.innerWidth/2-210),top:window.innerHeight/2,bottom:window.innerHeight/2,width:420};
const b=E.blocks[idx]; const el=E.getEl(b?b.id:null); if(el){const r=el.getBoundingClientRect();return r;} return {left:24,top:120,bottom:180,width:420}; },
_focusIngest(){ const i=this.ROOT.querySelector('.aici-input'); if(i){i.focus();} },
@@ -478,12 +478,17 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
const E=window.E; const ctx=(typeof window.fdGetPageContext==='function')?String(window.fdGetPageContext()||''):'';
if(!(window.fdAgent&&window.fdAgent.generate)){ this._err('Agent IA non configuré'); return; }
window.fdAgent.generate(prompt, ctx).then(function(text){
self._html=self._mdToHtml(text||''); self._stage='result'; self.paint();
// On conserve le MARKDOWN source : c'est lui qui est inséré (md2b),
// pas le texte extrait du HTML rendu qui perd titres/listes/gras.
self._md=String(text||'');
self._html=self._mdToHtml(self._md); self._stage='result'; self.paint();
}).catch(function(err){ self._err((err&&err.message)?err.message:'Erreur'); });
},
_err(msg){ this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
_err(msg){ this._md=''; this._html='<p style="color:#e5484d">⚠ '+esc(msg)+'</p>'; this._stage='result'; this.paint(); },
insert(){ const E=window.E; if(!E||typeof E.applyAIBlocks!=='function')return; E.sync();
const result=this._resultText(); if(!result)return; E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
// Markdown source d'abord ; repli sur le texte visible (erreurs).
const result=String(this._md||'').trim()||this._resultText(); if(!result)return;
E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); },
_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result'); if(!el)return ''; const r=document.createRange();r.selectNodeContents(el);return r.toString(); },
toast(m){ if(window.showToast)window.showToast(m,'info'); }
};
@@ -595,11 +600,19 @@ const PD=(()=>{try{const el=document.getElementById('page-data');return el?JSON.
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}${(b.style&&b.style.color)?';color:'+b.style.color:''}"`:((b.style&&(b.style.bgColor||b.style.color))?` style="${(b.style.bgColor?'background:'+b.style.bgColor+';':'')+(b.style.color?'color:'+b.style.color+';':'')}"`:'');
const bidAttr=b.type.startsWith('heading_')?` id="h-${(b.id||'').replace(/[^a-zA-Z0-9]/g,'')}"`:'';
// Numéro d'ordre : rang des blocs `numbered_list` contigus (1,2,3…) ;
// passé en attribut data-num → le CSS le rend hors du contenu éditable.
let numAttr='';
if(b.type==='numbered_list'&&idx>=0){
const bs=(window.E&&window.E.blocks)||[];
let s=idx; while(s>0&&bs[s-1]&&bs[s-1].type==='numbered_list')s--;
numAttr=` data-num="${idx-s+1}"`;
}
const actions=idx>=0?`<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>`:'';
return `<div class="block-wrapper" data-id="${b.id}">
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
${actions}
<${tag}${style}${bidAttr} class="${cls}">${inner}</${tag}>
<${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}</${tag}>
</div>`;
}
@@ -1970,6 +1983,12 @@ applyAIBlocks(text){
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();return;}
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
// La requête peut être tapée DANS le bloc (« /numbered list ») si le
// focus n'a pas rejoint l'entrée du menu → on la répercute au filtre.
if(SM._in&&(e.key.length===1||e.key==='Backspace')){
const q=(el.textContent||'').replace(/^\s*\//,'');
if(SM._in.value!==q){SM._in.value=q;SM.flt();}
}
return;
}
if(window.AIAC&&window.AIAC.active){
@@ -2486,10 +2505,12 @@ applyAIBlocks(text){
else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));
else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));
else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));
// AVANT les puces : « - [ ] … » commence aussi par « - » →
// sinon la case à cocher de l'agent devenait une puce.
else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));
else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));
else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));
else if(/^\d+[.)] /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+[.)] /,'')));
else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));
else bl.push(this.mkB('paragraph',t));
}return bl;},
+1 -1
View File
@@ -93,7 +93,7 @@ def test_mistral_defaults_are_tier_safe(client):
"""La clé Mistral plan basique → 403 « tier_not_allowed » sur mistral-large/pixtral-large.
Le modèle par défaut et le premier de la liste du test de connexion doivent
être des modèles servis par tous les plans."""
from app.services.llm_client import PROVIDERS, PROVIDER_MODELS
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
tier_blocked = {"mistral-large-latest", "pixtral-large-latest"}
assert PROVIDERS["mistral"][1] not in tier_blocked
assert PROVIDER_MODELS["mistral"][0] not in tier_blocked
+100
View File
@@ -0,0 +1,100 @@
"""7.59.2 — corrections : bouton ✕ de l'aide, « Insert below » qui perd le
format, listes numérotées invisibles (agent + /numbered list)."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
HELP_HTML = ROOT / "app" / "templates" / "help.html"
HELP_JS = ROOT / "static" / "js" / "help.js"
EDITOR_JS = ROOT / "static" / "js" / "page_editor_scripts.js"
APP_CSS = ROOT / "static" / "css" / "app.css"
SETTINGS_CSS = ROOT / "static" / "css" / "settings.css"
# ── 1. Aide : fermeture en haut à droite (mobile compris) ───────────────────
def test_help_page_has_close_button():
html = HELP_HTML.read_text(encoding="utf-8")
js = HELP_JS.read_text(encoding="utf-8")
css = SETTINGS_CSS.read_text(encoding="utf-8")
assert 'class="settings-close"' in html # bouton ✕ présent
assert '@click="close()"' in html
assert "close()" in js and "history.back()" in js # méthode réelle
assert ".settings-close{" in css # style partagé (32/44px)
assert "position:relative" in css.split(".settings-panel{")[1][:40]
# ── 2. Insert below : on insère le MARKDOWN, pas le texte du HTML rendu ─────
def test_ai_insert_below_keeps_source_markdown():
src = EDITOR_JS.read_text(encoding="utf-8")
assert "self._md=String(text||'')" in src # source conservée
assert "String(this._md||'').trim()||this._resultText()" in src # md d'abord
assert src.count("this._md='';") >= 2 # close() + _err()
# l'ancien chemin (texte extrait du DOM rendu) n'est plus que le repli
assert "_resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result')" in src
# ── 3. Listes numérotées : numéros réellement affichés ──────────────────────
def test_numbered_list_renders_its_number():
src = EDITOR_JS.read_text(encoding="utf-8")
css = APP_CSS.read_text(encoding="utf-8")
assert "let numAttr=''" in src and "data-num=" in src # rangée contiguë → rang
assert ".block-numbered[data-num]::before" in css
assert 'content: attr(data-num) ". "' in css
# md2b accepte « 1. » et « 1) » (CommonMark) pour l'insertion agent
assert r"/^\d+[.)] /" in src
def test_slash_query_typed_in_block_is_synced_to_filter():
src = EDITOR_JS.read_text(encoding="utf-8")
# « /numbered list » tapé dans le bloc → répercuté dans l'entrée du menu
assert "SM._in.value=q" in src
assert "SM.flt();" in src
# ── md2b : test comportemental sur la source réelle (node) ──────────────────
def _md2b_source() -> str:
src = EDITOR_JS.read_text(encoding="utf-8")
i = src.index("md2b(md){")
j = src.index("return bl;},", i) + len("return bl;},")
snippet = src[i:j].rstrip().rstrip(",") # « md2b(md){…} »
return snippet
def test_md2b_converts_numbered_lists_for_real():
node = shutil.which("node")
if not node:
pytest.skip("node absent : test comportemental md2b ignoré")
md = ("# Titre\n"
"1. premier\n"
"2. second\n"
"3) troisième\n"
"- puce\n"
"- [ ] à faire\n"
"> citation\n")
script = (
"function _tblIsData(){return false;}\n"
"function _tblSplitLine(){return [];}\n"
"const editor={mkB(t,c,e){const o={type:t,content:c||''};"
"if(e)Object.assign(o,e);return o;},\n"
f"{_md2b_source()}}};\n"
f"console.log(JSON.stringify(editor.md2b({json.dumps(md)})));\n"
)
out = subprocess.run([node, "-e", script], capture_output=True, text=True,
timeout=30, encoding="utf-8")
assert out.returncode == 0, out.stderr
blocks = json.loads(out.stdout)
assert [b["type"] for b in blocks] == [
"heading_1",
"numbered_list", "numbered_list", "numbered_list", # 1. 2. 3)
"bulleted_list", "to_do", "quote",
]
assert [b["content"] for b in blocks[1:4]] == ["premier", "second", "troisième"]