Compare commits

...
1 Commits
Author SHA1 Message Date
bruno d58c321c07 feat: réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection (v7.67.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
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).
2026-10-08 14:25:48 -04:00
16 changed files with 345 additions and 17 deletions
+56
View File
@@ -1,5 +1,61 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.67.0 (2026-10-08) — Réactions dans le tiroir Comments (+ suppression) et refonte du menu de sélection
### Added
- **Les réactions vivent dans le 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 « No comments yet » ne s'affiche plus s'il reste des réactions.
- **Suppression d'une réaction — deux entrées, un seul chemin** :
- **✕** sur la ligne de la section Reactions (visible uniquement sur **mes**
réactions) ;
- au **survol de la pastille**, l'émoji que j'ai posé devient une **croix**
(`::after`, aucun nœud ajouté → offsets source inchangés), un clic retire.
- les deux appellent `removeReaction()` → `POST /reactions` (toggle) →
`loadReactions()` + repeindre les ancres.
### Changed
- **Refonte du menu de sélection façon capture** :
- ligne 2 `[A] B I U T Tx` (le barré est conservé — absent de la maquette,
décision volontaire pour ne pas perdre la fonction) ;
- ligne 3 `🔗 S </> √x …` (la réaction en est sortie) ;
- **ligne 4 = une rangée scannée** : `💬 Comment` (libellé) à gauche,
`🙂+` réaction à droite (`.ft-row-split`) ;
- entête **Skills** avec `≡` à droite (`.ft-skills-head::after`), liste
**scrollable** (`.ft-skills`, `max-height` + `overflow-y`) ;
- **✨ Ask AI retiré** de la toolbar : doublon exact de « Edit with AI » du
pied (même `fmtAskAI()`).
### Fixed
- **Flake de gate corrigé (racine)** : `test_v55.py::TestOGParser::
test_fetch_og_metadata_success` dépendait d'un **vrai** `socket.getaddrinfo
("example.com")` via le garde SSRF `_is_public_host` ; sous `pytest -n auto`
un `socket.timeout` (non couvert par `except socket.gaierror`) remontait
dans le `except Exception` de `fetch_og_metadata` → repli silencieux
(`title='example.com'`) → AssertionError, 3 fois sur 5 runs.
- `_is_public_host` catch désormais **`OSError`** : le garde renvoie un
booléen, il ne lève jamais (les autres appelants gagnent aussi) ;
- fixture `stub_dns` sur `TestOGParser` (même motif que
`test_agent_web_tools.stub_dns`) : zéro DNS dans les tests OG.
### Tests
- `tests/test_v767_reactions_drawer.py` (3) ; `tests/test_v762_selection_toolbar.py`
(ligne 4 + styles) ; `tests/test_v764_comments_reactions.py` (position de
`fmtReact`).
- **e2e** : `v764_comments_reactions` étendu — section Reactions dans le tiroir
(texte ancré), **suppression A** (✕ du tiroir → pastille disparue),
**suppression B** (croix `::after` visible seulement au survol, puis clic) ;
`v762_selection_toolbar` — `Comment` + `ft-row-split .ft-react`,
`ft-skills-head` = « Skills », plus de bouton ✨.
- `pytest tests/ -q -n auto` → **1390 passed, 0 failed** (flake corrigé),
`ruff check app tests` → OK.
## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc ## v7.66.0 (2026-10-08) — Escape du menu slash retire le « / » + bouton « commenter » à droite du bloc
### Added ### Added
+23
View File
@@ -1501,6 +1501,29 @@ Détails livrés :
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**, `tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
`ruff` OK ; `VERSION` + `app/main.py` = 7.66.0 `ruff` OK ; `VERSION` + `app/main.py` = 7.66.0
### v7.67.0 — Réactions dans le tiroir + suppression + refonte du menu de sélection ✅ (2026-10-08)
> **Objectif** : une réaction s'ajoute/supprime depuis le tiroir Comments, et
> le menu de sélection correspond à la maquette ASCII fournie. **COMPLETED**.
- [x] **Section « Reactions » du tiroir** — `reactionList()` + `_anchorText()`
(texte de la plage lu dans le DOM, hors pastilles), compte si >1, état vide
adapté ; `removeReaction()` = POST toggle
- [x] **Suppression** — ✕ dans le tiroir (mes réactions seulement) **et**
croix `::after` au survol de la pastille sur l'émoji que j'ai posé (aucun
nœud ajouté → offsets source intacts)
- [x] **Refonte du menu** — ligne 4 scannée `💬 Comment | 🙂 réaction`,
réaction sortie de la ligne 3, entête `Skills ≡`, liste Skills scrollable,
`✨ Ask AI` retiré (doublon exact du pied)
- écart volontaire à la maquette : le **barré (T)** de la ligne 2 est
conservé (il n'y figure pas) pour ne pas perdre la fonction
- [x] **Flake de gate corrigé** — `_is_public_host` attrape `OSError` (le
garde SSRF ne lève plus) + fixture `stub_dns` sur `TestOGParser`
(le test faisait un vrai `getaddrinfo("example.com")`, échec sous `-n auto`)
- [x] **Tests** — `tests/test_v767_reactions_drawer.py` (3), e2e v764 étendu
(réactions dans le tiroir + suppression A/B), e2e v762 (nouvelle ligne 4) ;
`pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` +
`app/main.py` = 7.67.0
--- ---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code) ## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
+1 -1
View File
@@ -1 +1 @@
7.66.0 7.67.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone # WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.66.0 (Escape du menu slash retire le / + bouton commenter à droite du bloc) | **Statut**: EN COURS 🔄 > **Début**: 2026-07-08 | **Version**: v7.67.0 (réactions dans le tiroir Comments + suppression, refonte du menu de sélection) | **Statut**: EN COURS 🔄
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
## Avancement Global ## Avancement Global
+1 -1
View File
@@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.66.0", version="7.67.0",
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
+4 -1
View File
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
return False return False
try: try:
infos = socket.getaddrinfo(host, None) infos = socket.getaddrinfo(host, None)
except socket.gaierror: except OSError:
# gaierror, socket.timeout, herror… : le garde-fou doit TOUJOURS
# renvoyer un booléen, jamais lever — sinon l'appelant tombe dans son
# `except Exception` (bookmarks) au lieu d'un refus explicite.
return False return False
for info in infos: for info in infos:
address = info[4][0] address = info[4][0]
+22 -7
View File
@@ -590,7 +590,20 @@
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button> <button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
</div> </div>
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;"> <div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;"> <!-- v7.67 : les réactions emoji du texte vivent aussi ici (✕ = retirer) -->
<div class="ct-reactions" x-show="reactionList().length" x-cloak style="margin-bottom:14px;">
<div class="ct-section-title">Reactions</div>
<template x-for="r in reactionList()" :key="r.key">
<div class="ct-reaction">
<span class="ct-re-emoji" x-text="r.emoji"></span>
<span class="ct-re-count" x-show="r.count > 1" x-text="r.count"></span>
<span class="ct-re-text" x-text="r.text"></span>
<button type="button" class="ct-act" title="Remove reaction"
x-show="r.mine" @click="removeReaction(r)">✕</button>
</div>
</template>
</div>
<div x-show="comments.length === 0 && !reactionList().length" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
No comments yet. Select some text and click 💬 Comment. No comments yet. Select some text and click 💬 Comment.
</div> </div>
<template x-for="c in comments" :key="c.id"> <template x-for="c in comments" :key="c.id">
@@ -666,23 +679,25 @@
<div class="ft-row"> <div class="ft-row">
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button> <button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button> <button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block">&lt;/&gt;</button> <button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block">&lt;/&gt;</button>
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button> <button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button> <button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
</div> </div>
<!-- Ligne 4 : actions --> <!-- Ligne 4 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
<div class="ft-row"> doublon exact du bouton du pied, même action) -->
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button> <div class="ft-row ft-row-split">
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button> <button type="button" class="ft-item ft-comment" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}<span>Comment</span></button>
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
</div> </div>
<!-- Section « Skills » --> <!-- Section « Skills » -->
<div class="ft-sep"></div> <div class="ft-sep"></div>
<div class="ft-group">Skills</div> <div class="ft-group ft-skills-head">Skills</div>
<div class="ft-skills">
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button> <button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button> <button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button> <button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button> <button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
</div>
<!-- Pied de page --> <!-- Pied de page -->
<div class="ft-sep"></div> <div class="ft-sep"></div>
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI"> <button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "FlowDeck", "title": "FlowDeck",
"version": "7.66.0" "version": "7.67.0"
}, },
"paths": { "paths": {
"/auth/register": { "/auth/register": {
+6
View File
@@ -70,6 +70,12 @@ test('toolbar de sélection : structure, sous-menus, persistance', async ({ page
await expect(bar.locator('.ft-skill')).toHaveCount(4); await expect(bar.locator('.ft-skill')).toHaveCount(4);
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing'); await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI'); await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
// refonte v7.67 : ligne 4 = Comment (libellé) à gauche, réaction à droite ;
// entête Skills avec ≡ ; plus de bouton ✨ Ask AI (doublon du pied)
await expect(bar.locator('.ft-comment')).toContainText('Comment');
await expect(bar.locator('.ft-row-split .ft-react')).toHaveCount(1);
await expect(bar.locator('.ft-skills-head')).toHaveText('Skills');
await expect(bar.locator('[data-tooltip^="Ask AI"]')).toHaveCount(0);
// géométrie : sous la sélection, entièrement dans le viewport // géométrie : sous la sélection, entièrement dans le viewport
const barBox = await bar.boundingBox(); const barBox = await bar.boundingBox();
const selBox = await page.evaluate(() => { const selBox = await page.evaluate(() => {
+30
View File
@@ -168,4 +168,34 @@ test('réaction emoji + commentaires ancrés', async ({ page }) => {
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 }); await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un'); await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveText('reaction a un');
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 }); await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
// ── 9. v7.67 : la réaction vit aussi dans le tiroir Comments ──
await page.locator('.topbar-btn[title="Comments"]').click();
const re = page.locator('.ct-reactions');
await expect(re).toBeVisible();
await expect(re.locator('.ct-re-emoji')).toHaveCount(1);
await expect(re.locator('.ct-re-text')).toHaveText('Prise de reaction a un texte');
await page.screenshot({ path: 'shots/v764-drawer-reactions.png' });
// ── 10. suppression A : ✕ de la section Réactions ──
await re.locator('.ct-reaction .ct-act').click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
await expect(re).toBeHidden();
// ── 11. suppression B : la croix survolée de la pastille ──
await page.locator('.topbar-btn[title="Comments"]').click(); // referme le tiroir
await selectBlock(page);
await page.locator('.format-toolbar .ft-react').click();
const picker2 = page.locator('.fd-icon-picker');
await expect(picker2).toBeVisible();
await picker2.locator('.fdip-emoji').first().click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
const emo = page.locator('#_blocksCt .fd-react-emo.is-mine');
await expect(emo).toHaveCount(1);
// la croix n'apparaît qu'au survol (::after)
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('none');
await page.locator('#_blocksCt .fd-react-chip').hover();
await expect.poll(() => emo.evaluate((el) => getComputedStyle(el, '::after').display)).toBe('flex');
await emo.click();
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(0);
}); });
+77
View File
@@ -3040,6 +3040,30 @@ button.breadcrumb-current { max-width: 260px; }
letter-spacing: 1px; letter-spacing: 1px;
} }
/* ── v7.67 : refonte façon capture — ligne Comment | réaction aux 2 extrémités,
entête « Skills » avec ≡ à droite, liste Skills scrollable ── */
.format-toolbar .ft-row-split {
justify-content: space-between;
gap: 4px;
}
.format-toolbar .ft-comment {
justify-content: flex-start;
}
.format-toolbar .ft-skills-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.format-toolbar .ft-skills-head::after {
content: '≡';
font-size: 13px;
color: var(--text-dim);
}
.format-toolbar .ft-skills {
max-height: 132px;
overflow-y: auto;
}
/* Séparateurs, groupe « Skills », pied de page */ /* Séparateurs, groupe « Skills », pied de page */
.format-toolbar .ft-sep { .format-toolbar .ft-sep {
height: 1px; height: 1px;
@@ -3212,6 +3236,28 @@ button.breadcrumb-current { max-width: 260px; }
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; } .fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
.fd-react-n { font-size: 10px; color: var(--text-dim); } .fd-react-n { font-size: 10px; color: var(--text-dim); }
/* v7.67 : suppression d'une réaction — au survol de la pastille, l'émoji que
J'ai posé devient une croix (un seul clic = retirer). Pas de reflow : c'est
un ::after, les offsets source ne bougent pas. */
.fd-react-emo { position: relative; display: inline-flex; align-items: center; }
.fd-react-emo.is-mine::after {
content: '✕';
position: absolute;
inset: -3px;
display: none;
align-items: center;
justify-content: center;
font-size: 9px;
line-height: 1;
color: var(--text-dim);
background: var(--bg-modal, #1f1f1f);
border: 1px solid var(--border, #333);
border-radius: 50%;
cursor: pointer;
}
.fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }
.fd-react-emo.is-mine:hover::after { color: #e5484d; border-color: #e5484d; }
/* Tiroir de commentaires (fil + saisie) */ /* Tiroir de commentaires (fil + saisie) */
.comment-thread { .comment-thread {
position: relative; position: relative;
@@ -3251,6 +3297,37 @@ button.breadcrumb-current { max-width: 260px; }
.ct-menu-item.is-danger { color: #e5484d; } .ct-menu-item.is-danger { color: #e5484d; }
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); } .ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; } .ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
/* v7.67 : section Réactions du tiroir (liste des plages réagies + retirer) */
.ct-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: .5px;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 8px;
}
.ct-reaction {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 6px;
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border, #333);
border-radius: 8px;
font-size: 13px;
}
.ct-re-emoji { font-size: 14px; line-height: 1; flex-shrink: 0; }
.ct-re-count { font-size: 11px; color: var(--text-dim); }
.ct-re-text {
flex: 1;
min-width: 0;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; } .comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; } .ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
.ct-input-row textarea { .ct-input-row textarea {
+37 -1
View File
@@ -2695,6 +2695,30 @@ applyAIBlocks(text){
else this.showToast((d&&d.detail)||'Réaction impossible','error'); else this.showToast((d&&d.detail)||'Réaction impossible','error');
}catch(e){this.showToast('Réaction impossible','error');} }catch(e){this.showToast('Réaction impossible','error');}
}, },
// ── v7.67 : les réactions vivent aussi dans le tiroir Comments ──
// (une ligne par plage × émoji, déjà groupées par l'API)
reactionList(){
return (this.reactions||[]).map(r=>{
const bid=r.block_id,start=parseInt(r.anchor_start,10),end=parseInt(r.anchor_end,10);
return {key:bid+':'+start+':'+end+':'+r.emoji,bid:bid,start:start,end:end,emoji:r.emoji,
count:parseInt(r.count,10)||1,mine:!!parseInt(r.mine,10),
text:this._anchorText(bid,start,end)};
});
},
// suppression : POST toggle (le backend retire MA ligne si elle existe)
removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);},
// texte source d'une plage ancrée (hors pastilles .fd-react-chip)
_anchorText(bid,start,end){
const el=this.getEl(bid);if(!el||!(end>start))return '';
const w=document.createTreeWalker(el,NodeFilter.SHOW_TEXT);let n,pos=0,txt='';
while((n=w.nextNode())){
if(n.parentElement&&n.parentElement.closest('.fd-react-chip'))continue;
const len=n.nodeValue.length,a=Math.max(start,pos),b=Math.min(end,pos+len);
if(b>a)txt+=n.nodeValue.slice(a-pos,b-pos);
pos+=len;if(pos>=end)break;
}
return txt;
},
openThread(cid){ openThread(cid){
this.commentsOpen=true;this.commentMenu=null; this.commentsOpen=true;this.commentMenu=null;
const self=this; const self=this;
@@ -2751,7 +2775,13 @@ applyAIBlocks(text){
chip.setAttribute('data-react-bid',g.bid); chip.setAttribute('data-react-bid',g.bid);
chip.setAttribute('data-react-start',String(g.start)); chip.setAttribute('data-react-start',String(g.start));
chip.setAttribute('data-react-end',String(g.end)); chip.setAttribute('data-react-end',String(g.end));
chip.innerHTML=g.items.map(function(i){return '<span class="fd-react-emo">'+_emojiHtml(i.emoji)+'</span>';}).join('') // chaque émoji porte son data-emoji ; `is-mine` = j'ai réagi →
// la croix de suppression (CSS ::after au survol) ne s'affiche
// que là, un clic sur les autres rouvre le sélecteur.
chip.innerHTML=g.items.map(function(i){
const q=String(i.emoji||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
return '<span class="fd-react-emo'+(parseInt(i.mine,10)?' is-mine':'')+'" data-emoji="'+q+'">'+_emojiHtml(i.emoji)+'</span>';
}).join('')
+(n>1?'<span class="fd-react-n">'+n+'</span>':''); +(n>1?'<span class="fd-react-n">'+n+'</span>':'');
sp.parentNode.insertBefore(chip,sp.nextSibling); sp.parentNode.insertBefore(chip,sp.nextSibling);
}); });
@@ -3098,6 +3128,12 @@ applyAIBlocks(text){
const bid=chip.getAttribute('data-react-bid'); const bid=chip.getAttribute('data-react-bid');
const start=parseInt(chip.getAttribute('data-react-start'),10); const start=parseInt(chip.getAttribute('data-react-start'),10);
const end=parseInt(chip.getAttribute('data-react-end'),10); const end=parseInt(chip.getAttribute('data-react-end'),10);
// v7.67 : clic sur la croix d'un émoji à moi → retire MA réaction
const emo=t.closest('.fd-react-emo');
if(emo&&emo.classList.contains('is-mine')){
d.removeReaction({bid:bid,start:start,end:end,emoji:emo.getAttribute('data-emoji')});
return;
}
const el=d.getEl(bid);const rect=chip.getBoundingClientRect(); const el=d.getEl(bid);const rect=chip.getBoundingClientRect();
if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return; if(!window.FDIconPicker||!el||isNaN(start)||isNaN(end))return;
window.FDIconPicker.openFor({ window.FDIconPicker.openFor({
+22
View File
@@ -10,8 +10,10 @@ Covers the five roadmap points:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import ipaddress
import json import json
import secrets import secrets
import socket
from pathlib import Path from pathlib import Path
import httpx import httpx
@@ -209,7 +211,27 @@ _OG_HTML = """<!doctype html><html><head>
</head><body>hi</body></html>""" </head><body>hi</body></html>"""
#: IP publique factice — TestOGParser ne doit dépendre ni du réseau HTTP
#: (MockTransport) ni du DNS : `_is_public_host` résout l'hôte pour décider si
#: l'IP est publique, et cette résolution échoue sous `pytest -n auto` (même
#: piège que test_agent_web_tools.stub_dns — d'où un flake récurrent ici).
PUBLIC_IP = "93.184.216.34"
class TestOGParser: class TestOGParser:
@pytest.fixture(autouse=True)
def stub_dns(self, monkeypatch):
real = socket.getaddrinfo
def fake_getaddrinfo(host, *args, **kwargs):
try:
ipaddress.ip_address(host)
except ValueError:
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (PUBLIC_IP, 0))]
return real(host, *args, **kwargs)
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
def test_parse_og_handles_attribute_order(self): def test_parse_og_handles_attribute_order(self):
from app.services.og_fetcher import parse_og from app.services.og_fetcher import parse_og
+18 -2
View File
@@ -29,7 +29,7 @@ def test_toolbar_markup_matches_spec():
"fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx "fmtApply('strikeThrough')", "fmtCode()", # ligne 2 : A B I U T Tx
"fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()", "fmtLink()", "fmtHighlight()", "fmtCodeBlock()", "fmtEquation()",
"fmtMore($event)", # ligne 3 : 🔗 S </> √x … "fmtMore($event)", # ligne 3 : 🔗 S </> √x …
"fmtComment()", "fmtAskAI()", # ligne 4 : 💬 ✨ "fmtComment()", "fmtReact()", # ligne 4 : 💬 Comment | 🙂 réaction
"ft-group", "Improve writing", "Proofread", "Explain", "Reformat", "ft-group", "Improve writing", "Proofread", "Explain", "Reformat",
"Edit with AI", "Alt+⇧+E", # pied de page "Edit with AI", "Alt+⇧+E", # pied de page
] ]
@@ -41,7 +41,23 @@ def test_toolbar_markup_matches_spec():
assert "data-tooltip-html=" in chunk assert "data-tooltip-html=" in chunk
# icônes réellement définies dans le jeu d'icônes # icônes réellement définies dans le jeu d'icônes
assert 'fd_icon("message-square",15)' in chunk assert 'fd_icon("message-square",15)' in chunk
assert 'fd_icon("sparkles",15)' in chunk and 'fd_icon("link",15)' in chunk assert 'fd_icon("link",15)' in chunk
def test_toolbar_row4_matches_reference_layout():
"""v7.67 — ligne Comment à gauche, réaction à droite, Skills avec ≡."""
chunk = _slice(TPL, 'class="format-toolbar"', "Copy Link Toast")
# ligne 4 = une seule rangée scannée, boutons aux 2 extrémités
assert 'ft-row-split' in chunk
assert '<span>Comment</span>' in chunk # libellé, pas juste l'icône
assert chunk.index("fmtComment()") < chunk.index("fmtReact()")
# l'✨ Ask AI a sauté : doublon exact du « Edit with AI » du pied
assert '"fmtAskAI()"' not in chunk
# entête Skills (≡ en CSS) + liste scrollable
assert 'ft-skills-head' in chunk and 'ft-skills' in chunk
for sel in (".format-toolbar .ft-row-split {", ".format-toolbar .ft-comment {",
".format-toolbar .ft-skills-head::after {", ".format-toolbar .ft-skills {"):
assert sel in CSS, sel
# ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ──────── # ── Actions : câblées vers de vraies méthodes, sous-menus réutilisés ────────
+2 -2
View File
@@ -71,9 +71,9 @@ def test_toggle_reaction_api(client):
def test_toolbar_has_react_button(): def test_toolbar_has_react_button():
assert 'fmtReact()' in TPL assert 'fmtReact()' in TPL
assert 'data-tooltip="React to selected text"' in TPL assert 'data-tooltip="React to selected text"' in TPL
# position : 3ᵉ ligne, après le surlignage, avant le code # position (v7.67) : 4ᵉ ligne, à droite de « Comment », après les outils
chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")] chunk = TPL[TPL.index('class="format-toolbar"'):TPL.index("Copy Link Toast")]
order = ["fmtHighlight()", "fmtReact()", "fmtCodeBlock()", "fmtMore($event)"] order = ["fmtMore($event)", "fmtComment()", "fmtReact()", "ft-group"]
positions = [chunk.index(o) for o in order] positions = [chunk.index(o) for o in order]
assert positions == sorted(positions), positions assert positions == sorted(positions), positions
+44
View File
@@ -0,0 +1,44 @@
"""7.67.0 — réactions emoji visibles dans le tiroir Comments + suppression."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
TPL = (ROOT / "app" / "templates" / "_page_editor_content.html").read_text(encoding="utf-8")
CSS = (ROOT / "static" / "css" / "app.css").read_text(encoding="utf-8")
def test_drawer_lists_reactions():
# section « Reactions » du tiroir : une ligne par plage × émoji
assert 'class="ct-reactions"' in TPL
assert 'x-for="r in reactionList()"' in TPL
assert '<span class="ct-re-emoji" x-text="r.emoji"></span>' in TPL
assert '<span class="ct-re-text" x-text="r.text"></span>' in TPL
# l'état vide ne s'affiche plus s'il reste des réactions
assert "comments.length === 0 && !reactionList().length" in TPL
# méthode : groupes déjà prêts côté API, texte source lu dans le DOM
assert "reactionList(){" in SRC
assert "_anchorText(bid,start,end)" in SRC
# le texte source ignore les pastilles (offsets identiques à sync())
assert "closest('.fd-react-chip'))continue;" in SRC
def test_reaction_removable_from_the_drawer():
assert 'title="Remove reaction"' in TPL
# le ✕ n'apparaît que sur MA réaction (les autres comptes sont en lecture)
assert 'x-show="r.mine" @click="removeReaction(r)"' in TPL
# suppression = POST toggle (le backend DELETE si ma ligne existe déjà)
assert "removeReaction(r){if(!r)return;this.toggleReaction(r.bid,r.start,r.end,r.emoji);}" in SRC
for sel in (".ct-section-title {", ".ct-reaction {", ".ct-re-emoji {", ".ct-re-text {"):
assert sel in CSS, sel
def test_reaction_removable_from_the_chip():
# chaque émoji de la pastille porte son data-emoji, `is-mine` = j'ai réagi
assert 'data-emoji="' in SRC
assert "parseInt(i.mine,10)?' is-mine':''" in SRC
assert ("d.removeReaction({bid:bid,start:start,end:end,"
"emoji:emo.getAttribute('data-emoji')})") in SRC
# la croix est un ::after (aucun nœud ajouté → offsets source inchangés)
assert ".fd-react-emo.is-mine::after {" in CSS
assert ".fd-react-chip:hover .fd-react-emo.is-mine::after { display: flex; }" in CSS