diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b6d91..c91d50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index b50130e..f06905a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.59.1 +7.59.2 diff --git a/app/main.py b/app/main.py index 1d69fe4..c9340e8 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/templates/help.html b/app/templates/help.html index a485252..5c79132 100644 --- a/app/templates/help.html +++ b/app/templates/help.html @@ -35,6 +35,7 @@ +
diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 5313079..5c617ba 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -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": { diff --git a/static/css/app.css b/static/css/app.css index 83f9ab7..465ada7 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -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 { diff --git a/static/css/settings.css b/static/css/settings.css index 705a091..6933885 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -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;} diff --git a/static/js/help.js b/static/js/help.js index 7450c28..07a6cde 100644 --- a/static/js/help.js +++ b/static/js/help.js @@ -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; diff --git a/static/js/page_editor_scripts.js b/static/js/page_editor_scripts.js index ccbb508..837e915 100644 --- a/static/js/page_editor_scripts.js +++ b/static/js/page_editor_scripts.js @@ -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='

⚠ '+esc(msg)+'

'; this._stage='result'; this.paint(); }, + _err(msg){ this._md=''; this._html='

⚠ '+esc(msg)+'

'; 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?`
`:''; return `
${actions} - <${tag}${style}${bidAttr} class="${cls}">${inner} + <${tag}${style}${bidAttr}${numAttr} class="${cls}">${inner}
`; } @@ -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;}, diff --git a/tests/test_agent.py b/tests/test_agent.py index 5173a88..3b4fd28 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -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 diff --git a/tests/test_v7592_ui_fixes.py b/tests/test_v7592_ui_fixes.py new file mode 100644 index 0000000..02e106f --- /dev/null +++ b/tests/test_v7592_ui_fixes.py @@ -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"]