feat(assistant): #91 refonte Notion de la zone de discussion — question ancrée en haut, fil chronologique, étapes repliables, barre Copier/Ajouter
CI / lint (push) Successful in 1m23s
CI / security (push) Successful in 55s
CI / test (push) Successful in 2m55s
CI / build (push) Successful in 50s
CI / e2e (push) Successful in 10m58s

This commit is contained in:
2026-09-15 15:51:19 -04:00
parent 256f5a4a03
commit cdb4d29676
9 changed files with 4114 additions and 3936 deletions
+11 -8
View File
@@ -43,15 +43,18 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
(pas de TOC, pas de PDF/Export). La fenêtre pop-out (`popout.html`) est (pas de TOC, pas de PDF/Export). La fenêtre pop-out (`popout.html`) est
alignée sur le même ordre markdown (groupes nav / édition / export / partage). alignée sur le même ordre markdown (groupes nav / édition / export / partage).
Tests : `tests/frontend/toolbar-order.test.mjs` (9). Tests : `tests/frontend/toolbar-order.test.mjs` (9).
- **#91 Assistant IA — fenêtre de résultats améliorée** : un message soumis et la - **#91 Assistant IA — zone de discussion façon Notion** : le fil de discussion est
réponse de l'assistant **en cours d'écriture** sont épinglés en haut de la fenêtre reformaté (messages chronologiques, post utilisateur en bulle alignée à droite,
de messages (`.bookslm-msg.latest { order: -1 }`), pour toujours voir le résultat réponse assistant pleine largeur sans fond, appels d'outils agent repliés en
que l'assistant rédige ; un libellé **discret** au-dessus de chaque réponse rapporte bloc « N étapes ») ; après l'envoi, la **question est ancrée en haut** de la zone
le fournisseur et le modèle réellement utilisés (SSE `provider`/`model`) ; un bouton visible (`scrollIntoView` `block:start` + `scroll-margin-top`) et la réponse se
« Copier » sous chaque bloc (utilisateur et assistant) copie le texte brut dans le diffuse en dessous — le streaming ne provoque plus de saut de scroll ; un libellé
presse-papiers avec toast de confirmation. Fiche : **discret** au-dessus de chaque réponse rapporte le fournisseur et le modèle
réellement utilisés (SSE `provider`/`model`) ; une **barre d'actions** au survol
sous chaque bloc : « Copier » (texte brut + toast) et, pour les réponses,
« Ajouter » (insertion dans le document ouvert dans l'éditeur Forge). Fiche :
[docs/features/ai-assistant-conversation-ux.md](./docs/features/ai-assistant-conversation-ux.md) [docs/features/ai-assistant-conversation-ux.md](./docs/features/ai-assistant-conversation-ux.md)
Tests : `tests/frontend/ai.test.mjs` (71). Tests : `tests/frontend/ai.test.mjs` (74).
### Corrigé ### Corrigé
+1 -1
View File
@@ -171,7 +171,7 @@
| 84 | Consolidation & sécurité — revue statique 2026-09-13 (phase 1, BUG-021→034) | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 84 | Consolidation & sécurité — revue statique 2026-09-13 (phase 1, BUG-021→034) | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 83 | Barre d'outils d'édition mobile — ruban style Obsidian Android | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) | | 83 | Barre d'outils d'édition mobile — ruban style Obsidian Android | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 88 | Assistant IA — contexte applicatif (documents ouverts, répertoire, recherche, fichiers récents) & liens de fichiers fiables (BUG-041, BUG-042) | 2.3.0 | [features/ai-app-context.md](./features/ai-app-context.md) | | 88 | Assistant IA — contexte applicatif (documents ouverts, répertoire, recherche, fichiers récents) & liens de fichiers fiables (BUG-041, BUG-042) | 2.3.0 | [features/ai-app-context.md](./features/ai-app-context.md) |
| 91 | Assistant IA — Fenêtre de résultats : post en haut, fournisseur/modèle discret & bouton copier | 2.3.0 | [features/ai-assistant-conversation-ux.md](./features/ai-assistant-conversation-ux.md) | | 91 | Assistant IA — Zone de discussion façon Notion : post ancré en haut, fournisseur/modèle discret & barre d'actions | 2.3.0 | [features/ai-assistant-conversation-ux.md](./features/ai-assistant-conversation-ux.md) |
--- ---
+71 -47
View File
@@ -1,57 +1,81 @@
# #91 — Assistant IA — Fenêtre de résultats : post en haut, fournisseur/modèle & bouton copier # #91 — Assistant IA — Zone de discussion façon Notion : post ancré en haut, fournisseur/modèle discret & barre d'actions
> **Statut :** ✅ Livré > **Statut :** ✅ Livré
> **Effort :** 0,5-1 jour | **Impact :** 🟡 > **Effort :** 0,5-1 jour | **Impact :** 🟡
> **Références :** [Roadmap](../ROADMAP.md) · [BooksLM #76](./bookslm.md) · [Assistant UX #80](./ai-assistant-ux.md) · [Changelog](../../CHANGELOG.md) > **Références :** [Roadmap](../ROADMAP.md) · [BooksLM #76](./bookslm.md) · [Assistant UX #80](./ai-assistant-ux.md) · [Changelog](../../CHANGELOG.md)
- **Description :** Trois améliorations d'expérience dans la fenêtre de résultats de - **Description :** Refonte de la présentation de la fenêtre de résultats de
l'assistant IA (`frontend/js/bookslm.js`, `frontend/style.css`) : l'assistant IA (`frontend/js/bookslm.js`, `frontend/style.css`) sur le modèle de
1. **Post toujours en haut** : dès qu'un message est soumis, ce tour (le post l'assistant Notion :
utilisateur **et** la réponse de l'assistant en cours d'écriture) est épinglé en 1. **Ancrage en haut** : après l'envoi, le post de l'utilisateur est amené tout en
haut de la fenêtre de messages, afin que le résultat écrit par l'assistant reste haut de la zone visible (`scrollIntoView({ block: 'start' })` +
toujours visible — même après re-render successifs et auto-scroll. `scroll-margin-top`) ; la réponse se diffuse **en dessous**, lisible sans
2. **Fournisseur & modèle discrets** : juste au-dessus du bloc de texte rédigé par défilement. Plus de scroll-into-bottom ni d'astuce `order: -1`.
l'assistant, un libellé discret rapporte le fournisseur et le modèle réellement 2. **Fil Notion-style** : messages en ordre chronologique ; post utilisateur =
utilisés (fournis par le backend via le flux SSE `{token, provider, model}`). bulle arrondie alignée à droite (max 80 %) ; réponse assistant = texte pleine
3. **Bouton « Copier »** : sous le bloc soumis par l'utilisateur ainsi que sous le largeur **sans bulle de fond** ; les appels d'outils du mode agent se replient
bloc rédigé par l'assistant, un bouton copie le texte brut de ce bloc dans le dans un bloc discret « N étapes » (`<details>`).
presse-papiers (avec toast de confirmation). 3. **Fournisseur & modèle discrets** : libellé au-dessus de chaque réponse,
issu du SSE réel du backend (`provider`/`model`).
4. **Barre d'actions au survol** : sous chaque bloc non vide, « Copier »
(utilisateur et assistant) ; sous une réponse, « Ajouter » qui insère le texte
dans le document ouvert dans l'éditeur Forge (aucun bouton factice).
## A. Post toujours en haut — ✅ livré ## A. Positionnement automatique (ancre en haut) — ✅ livré
- [x] **A1.** Chaque message est enveloppé dans un bloc `.bookslm-msg` ; le conteneur - [x] **A1.** `_renderMessages({ anchor: true })` appelé à l'envoi du message, à
`.bookslm-messages` est un flex column et les blocs du **dernier tour** (depuis le l'ouverture d'un contexte, au rechargement d'une session et à la reprise après
dernier message utilisateur inclus) portent `.latest` → `order: -1`, les épinglant confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView`
en haut de la fenêtre. fluide `block: 'start'`, repli calcul `scrollTop += delta` si indisponible.
- [x] **A2.** La détection du dernier tour se fait au rendu (`_renderMessages()`) en - [x] **A2.** Re-rendus de streaming : la position de scroll est **préservée**
recherchant le dernier message `role === 'user'` ; tout ce qui suit (sa réponse, ses (plus de `scrollTop = scrollHeight`) — la réponse s'allonge sous la question
actions/cartes) reste groupé au-dessus de l'historique antérieur. sans jamais déplacer la vue.
- [x] **A3.** L'auto-scroll vers le bas (`scrollTop = scrollHeight`) conserve le dernier - [x] **A3.** Marge supérieure : `scroll-margin-top: 14px` sur `.bookslm-msg`
tour en haut, hors d'atteinte du contenu précédent. pour que le post ne colle pas au bord.
- [x] **A4.** Seule la zone du fil défile : `.bookslm-messages { flex: 1; overflow-y: auto }`
(en-tête, toolbar, statut et barre de saisie fixes — structure déjà en place).
## B. Fournisseur & modèle discrets — ✅ livré ## B. Fil chronologique Notion-style — ✅ livré
- [x] **B1.** `_streamResponse()` capture `data.provider` et `data.model` du flux SSE - [x] **B1.** Ordre naturel (user puis assistant, empilés, `gap: 24px`).
(déjà émis par `/chat` et `/agent`) sur le message assistant. - [x] **B2.** Utilisateur : bulle `--surface2` alignée à droite, `max-width: 80%`,
- [x] **B2.** Au rendu d'un message assistant, `.bookslm-msg-meta` affiche coins arrondis 18px.
« `fournisseur · modèle` » au-dessus du bloc (visible uniquement si l'info est reçue). - [x] **B3.** Assistant : `width: 100%`, pas de fond ni de padding de bulle —
- [x] **B3.** Style discret (10px, `var(--text-secondary)`), non sélectionnable. le markdown occupe toute la largeur (titres, listes, code, sources inchangés).
- [x] **B4.** Mode agent : `_renderToolActivity()` → `<details class="bookslm-tool-trace">`
avec `<summary>` « N étapes » (i18n `ai.steps_count`) ; les lignes d'outils
restent accessibles en déroulant le bloc.
## C. Bouton « Copier » — ✅ livré ## C. Fournisseur & modèle discrets — ✅ livré
- [x] **C1.** `_appendCopyButton()` ajoute un bouton `.bookslm-copy-btn` sous chaque - [x] **C1.** `_streamResponse()` capture `data.provider` / `data.model` du flux SSE
bloc non vide (utilisateur et assistant) avec le texte i18n `bookslm.copy`. (déjà émis par `/chat` et `/agent`) sur le message assistant.
- [x] **C2.** `_copyText()` copie le **texte brut** du message (`navigator.clipboard`, - [x] **C2.** `.bookslm-msg-meta` au-dessus du bloc assistant :
repli `_fallbackCopy`) et affiche le toast `bookslm.copied`. « fournisseur · modèle » (persisté avec la session, visible au rechargement).
- [x] **C3.** Le bouton est masqué par défaut (opacité 0) et révélé au survol du bloc
`.bookslm-msg`.
## D. Tests & documentation — ✅ livré ## D. Barre d'actions — ✅ livré
- [x] **D1.** `tests/frontend/ai.test.mjs` : dernier tour épinglé, tag fournisseur/modèle - [x] **D1.** `_appendActionBar()` sous chaque bloc non vide : bouton Copier pour
rendu, boutons copier présents sur les deux blocs, copie du texte brut, absence de les deux rôles ; bouton Ajouter pour l'assistant uniquement.
bouton pour un contenu vide. - [x] **D2.** Copier : texte brut du message (`navigator.clipboard`, repli
- [x] **D2.** i18n FR/EN : `bookslm.copied`. `execCommand`) + toast `bookslm.copied`.
- [x] **D3.** CHANGELOG + Roadmap. - [x] **D3.** Ajouter (`_insertIntoEditor`) : insertion du texte à la position de
fin de sélection dans `state.editorView` (Forge) ; toast `bookslm.inserted` ou
`bookslm.insert_no_editor` si aucun éditeur ouvert.
- [x] **D4.** Révélation au survol / focus clavier (`.bookslm-msg:hover`,
`:focus-within`) — icônes Lucide `copy` / `corner-down-left`.
## E. Points d'attention ## E. Tests & documentation — ✅ livré
- Le tag provient du **backend réel** (`provider`/`model` du SSE) et reflète donc le - [x] **E1.** `tests/frontend/ai.test.mjs` : ordre chronologique, ancrage
modèle effectif même lorsque l'utilisateur conserve la sélection « par défaut ». `scrollIntoView block:start`, tag provider/modèle, barre d'actions par rôle,
- Le copier utilise le texte brut (pas le HTML rendu) — cohérent avec l'export de copie du texte brut, insertion éditeur (mock `state.editorView`), bloc
conversation et évite d'embarquer des liens/balises de rendu. repliable des étapes, absence de barre pour contenu vide.
- [x] **E2.** i18n FR/EN : `bookslm.copied`, `bookslm.insert`, `bookslm.insert_hint`,
`bookslm.inserted`, `bookslm.insert_no_editor`, `ai.steps_count`.
- [x] **E3.** CHANGELOG + Roadmap ; `SW_VERSION` bump (cache bust).
## F. Points d'attention
- Le tag fournisseur provient du **backend réel** (SSE), donc correct même avec la
sélection « par défaut » du fournisseur.
- Le streaming appelle `_renderMessages()` à chaque chunk : l'ancre est posée une
seule fois à l'envoi, les re-rendus préservent ensuite le scroll — pas de sauts.
- L'ancien épinglage flex `order: -1` (première itération de #91) est **remplacé** :
l'ordre est redevenu chronologique, l'ancrage est fait par défilement explicite.
- Les pouces haut/bas Notion ne sont **pas** repris : aucun signal n'existe
côté backend ; « Ajouter » (insertion éditeur réelle) les remplace utilement.
+116 -44
View File
@@ -215,7 +215,7 @@ class BooksLM {
this._setHidden(false); this._setHidden(false);
this._updateHeader(); this._updateHeader();
this._updateStatus(); this._updateStatus();
this._renderMessages(); this._renderMessages({ anchor: true });
this._showSuggestions(); this._showSuggestions();
this._renderAttachments(); this._renderAttachments();
this._closeHistoryMenu(); this._closeHistoryMenu();
@@ -1492,7 +1492,7 @@ class BooksLM {
_pushAssistantNote(markdown) { _pushAssistantNote(markdown) {
this._messages.push({ role: 'assistant', content: markdown, sources: [], toolCalls: [], confirmation: null }); this._messages.push({ role: 'assistant', content: markdown, sources: [], toolCalls: [], confirmation: null });
this._renderMessages(); this._renderMessages({ anchor: true });
this._saveHistory(); this._saveHistory();
} }
@@ -1777,42 +1777,62 @@ class BooksLM {
} }
} }
_renderMessages() { _renderMessages(opts = {}) {
if (!this._panel) return; if (!this._panel) return;
const container = this._panel.querySelector('.bookslm-messages'); const container = this._panel.querySelector('.bookslm-messages');
if (!container) return; if (!container) return;
const prevTop = container.scrollTop;
container.innerHTML = ''; container.innerHTML = '';
// The "latest turn" — the most recent user post and, when present, its // Notion-style thread: chronological order, the user post anchored at the
// assistant answer — is pinned to the top of the window. Everything from // top of the visible area (never scroll-to-bottom — the answer streams
// the last user message onward stays in view so the submitted post and // below the question). Streaming re-renders preserve the scroll position
// the answer being written are always visible. // so the reader is never jumped around.
let lastUserIdx = -1; for (const msg of this._messages) {
for (let i = this._messages.length - 1; i >= 0; i--) { container.appendChild(this._chatBubble(msg));
if (this._messages[i].role === 'user') { lastUserIdx = i; break; }
} }
for (let i = 0; i < this._messages.length; i++) { if (opts.anchor) this._anchorLatestUser();
const msg = this._messages[i]; else container.scrollTop = prevTop;
const bubble = this._chatBubble(msg, i >= lastUserIdx && lastUserIdx >= 0);
container.appendChild(bubble);
}
container.scrollTop = container.scrollHeight;
} }
/** /**
* Build a single chat bubble (shared by user and assistant messages). * Scroll the messages window so the most recent user post sits at the very
* * top of the viewport (its `scroll-margin-top` keeps a breathing gap),
* Each bubble is wrapped in a `bookslm-msg` block. Bubbles flagged as part * reproducing Notion-AI's conversation anchoring.
* of the latest turn carry `.latest` and are pinned to the very top of the
* messages window (`.bookslm-msg.latest { order: -1; }`), so a submitted
* post and the answer being streamed stay visible regardless of auto-scroll
* to the bottom and of prior conversation history.
*/ */
_chatBubble(msg, isLatest) { _anchorLatestUser() {
if (!this._panel) return;
const container = this._panel.querySelector('.bookslm-messages');
if (!container) return;
const users = container.querySelectorAll('.bookslm-msg.user');
const all = container.querySelectorAll('.bookslm-msg');
// Fallback (admin notes with no user post): anchor the newest message.
const el = users[users.length - 1] || all[all.length - 1] || container.firstElementChild;
if (!el) return;
const apply = () => {
try {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch {
const cRect = container.getBoundingClientRect();
const eRect = el.getBoundingClientRect();
container.scrollTop += eRect.top - cRect.top;
}
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(apply);
else apply();
}
/**
* Build a single chat entry (user or assistant), Notion-style:
* • user → right-aligned rounded grey bubble (max 80%),
* • assistant → full-width text with no background bubble, a discreet
* provider/model tag above, a collapsible “N steps” block for agent
* tool calls, and a hover-revealed action bar (Copier) below.
*/
_chatBubble(msg) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'bookslm-msg ' + msg.role + (isLatest ? ' latest' : ''); wrap.className = 'bookslm-msg ' + msg.role;
if (msg.role === 'assistant') { if (msg.role === 'assistant') {
// Discreet provider/model tag above the answer. Uses the actual engine // Discreet provider/model tag above the answer. Uses the actual engine
@@ -1826,13 +1846,15 @@ class BooksLM {
metaLine.title = label; metaLine.title = label;
wrap.appendChild(metaLine); wrap.appendChild(metaLine);
} }
// Notion-style: the tool calls collapse into a “N steps” line above the
// answer instead of a visible list.
if (msg.toolCalls && msg.toolCalls.length) {
wrap.appendChild(this._renderToolActivity(msg.toolCalls));
}
const bubble = document.createElement('div'); const bubble = document.createElement('div');
bubble.className = 'bookslm-bubble assistant'; bubble.className = 'bookslm-bubble assistant';
const { text, actions } = this._extractActions(msg.content || ''); const { text, actions } = this._extractActions(msg.content || '');
bubble.innerHTML = this._renderMarkdown(text); bubble.innerHTML = this._renderMarkdown(text);
if (msg.toolCalls && msg.toolCalls.length) {
bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild);
}
if (actions.length) { if (actions.length) {
const actionsDiv = document.createElement('div'); const actionsDiv = document.createElement('div');
actionsDiv.className = 'bookslm-actions'; actionsDiv.className = 'bookslm-actions';
@@ -1863,26 +1885,71 @@ class BooksLM {
wrap.appendChild(bubble); wrap.appendChild(bubble);
} }
// Copy button under the message block. // Notion-style action bar revealed on hover of the message block.
this._appendCopyButton(wrap, msg); this._appendActionBar(wrap, msg);
return wrap; return wrap;
} }
/** Copy the raw message text to the clipboard (user or assistant block). */ /** Hover action bar under a message: Copy (both roles) + Add (assistant). */
_appendCopyButton(wrap, msg) { _appendActionBar(wrap, msg) {
const content = String(msg.content || '').trim(); const content = String(msg.content || '').trim();
if (!content) return; if (!content) return;
const bar = document.createElement('div');
bar.className = 'bookslm-msg-actions';
bar.appendChild(this._actionBtn(t('bookslm.copy'), 'copy', t('bookslm.copy'), () => {
this._copyText(content);
}));
if (msg.role === 'assistant') {
bar.appendChild(this._actionBtn(t('bookslm.insert'), 'insert', t('bookslm.insert_hint'), () => {
this._insertIntoEditor(content);
}));
}
wrap.appendChild(bar);
}
_actionBtn(label, name, title, onClick) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = 'bookslm-copy-btn'; btn.className = `bookslm-msg-action bookslm-msg-action-${name}`;
btn.textContent = t('bookslm.copy'); btn.title = title;
btn.title = t('bookslm.copy'); btn.setAttribute('aria-label', title);
btn.setAttribute('aria-label', t('bookslm.copy')); const icon = document.createElement('span');
btn.addEventListener('click', () => { icon.setAttribute('data-lucide', name === 'copy' ? 'copy' : 'corner-down-left');
this._copyText(content); icon.style.cssText = 'width:12px;height:12px';
}); btn.appendChild(icon);
wrap.appendChild(btn); const text = document.createElement('span');
text.textContent = label;
btn.appendChild(text);
btn.addEventListener('click', onClick);
if (typeof safeCreateIcons === 'function') safeCreateIcons();
return btn;
}
/**
* Append an assistant answer to the Forge editor document (Notion “Ajouter”).
* No-ops with a toast when no editor session is open.
*/
_insertIntoEditor(text) {
const view = state.editorView;
if (!view || !view.state || typeof view.dispatch !== 'function') {
showToast(t('bookslm.insert_no_editor'), 'info');
return;
}
try {
const at = view.state.selection.main.to;
const insert = (at > 0 ? '\n' : '') + text;
view.dispatch({
changes: { from: at, insert },
selection: { anchor: at + insert.length },
});
if (typeof view.focus === 'function') view.focus();
showToast(t('bookslm.inserted'), 'success');
} catch (e) {
console.warn('AI assistant: insert into editor failed', e);
showToast(t('bookslm.insert_no_editor'), 'error');
}
} }
_copyText(text) { _copyText(text) {
@@ -1975,9 +2042,13 @@ class BooksLM {
// ── Tool activity & confirmations (agent mode) ────────────────────── // ── Tool activity & confirmations (agent mode) ──────────────────────
/** Agent tool calls collapse into a discreet “N steps” line (Notion-style). */
_renderToolActivity(toolCalls) { _renderToolActivity(toolCalls) {
const wrap = document.createElement('div'); const wrap = document.createElement('details');
wrap.className = 'bookslm-tool-trace'; wrap.className = 'bookslm-tool-trace';
const summary = document.createElement('summary');
summary.textContent = t('ai.steps_count', { count: toolCalls.length });
wrap.appendChild(summary);
for (const call of toolCalls) { for (const call of toolCalls) {
const line = document.createElement('div'); const line = document.createElement('div');
line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : ''); line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : '');
@@ -2128,6 +2199,7 @@ class BooksLM {
// The confirmation is resolved: drop the card and show the continuation. // The confirmation is resolved: drop the card and show the continuation.
msg.confirmation = null; msg.confirmation = null;
this._messages.push(continuation); this._messages.push(continuation);
this._renderMessages({ anchor: true });
await this._streamResponse(resp, continuation, payload); await this._streamResponse(resp, continuation, payload);
} finally { } finally {
conf._applying = false; conf._applying = false;
@@ -2178,7 +2250,7 @@ class BooksLM {
.map((m) => ({ role: m.role, content: m.content })); .map((m) => ({ role: m.role, content: m.content }));
this._messages.push({ role: 'user', content: text }); this._messages.push({ role: 'user', content: text });
this._renderMessages(); this._renderMessages({ anchor: true });
let provider = null; let provider = null;
let model = null; let model = null;
@@ -2764,7 +2836,7 @@ class BooksLM {
this._messages = Array.isArray(session.messages) ? session.messages : []; this._messages = Array.isArray(session.messages) ? session.messages : [];
this._closeHistoryMenu(); this._closeHistoryMenu();
if (this._panel) { if (this._panel) {
this._renderMessages(); this._renderMessages({ anchor: true });
this._showSuggestions(); this._showSuggestions();
this._renderHistoryMenu(); this._renderHistoryMenu();
} }
+1876 -1871
View File
File diff suppressed because it is too large Load Diff
+1876 -1871
View File
File diff suppressed because it is too large Load Diff
+30 -16
View File
@@ -9434,22 +9434,30 @@ body.popup-mode .content-area {
.bookslm-activity.done .bookslm-activity-spinner, .bookslm-activity.done .bookslm-activity-spinner,
.bookslm-activity.error .bookslm-activity-spinner { animation: none; border-top-color: currentColor; } .bookslm-activity.error .bookslm-activity-spinner { animation: none; border-top-color: currentColor; }
@keyframes bookslm-spin { to { transform: rotate(360deg); } } @keyframes bookslm-spin { to { transform: rotate(360deg); } }
.bookslm-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; } /* Notion-style AI thread: only this zone scrolls; entries stack vertically
/* The newest turn (the assistant's answer being written) is pinned to the with generous spacing. The latest user post is anchored to the top of the
very top of the messages window via flex `order`, so a submitted post and visible area on send (scrollIntoView block:start + scroll-margin). */
its live result stay visible regardless of auto-scroll and prior history. */ .bookslm-messages { flex: 1; overflow-y: auto; padding: 20px 16px; display: flex;
.bookslm-msg { display: flex; flex-direction: column; gap: 4px; } flex-direction: column; gap: 24px; }
.bookslm-msg.latest { order: -1; } .bookslm-msg { display: flex; flex-direction: column; gap: 4px; scroll-margin-top: 14px; }
.bookslm-msg-meta { font-size: 10px; color: var(--text-secondary); opacity: 0.85; .bookslm-msg.user { align-self: flex-end; max-width: 80%; }
.bookslm-msg.assistant { align-self: stretch; width: 100%; }
.bookslm-msg-meta { font-size: 11px; color: var(--text-secondary); opacity: 0.75;
padding: 0 2px; align-self: flex-start; user-select: none; } padding: 0 2px; align-self: flex-start; user-select: none; }
.bookslm-copy-btn { align-self: flex-end; font-size: 10px; padding: 2px 8px; /* Hover-revealed action bar (Copier / Ajouter). */
border-radius: 6px; border: 1px solid var(--border); background: var(--surface); .bookslm-msg-actions { display: flex; gap: 6px; margin-top: 4px; opacity: 0;
color: var(--text-secondary); cursor: pointer; opacity: 0; transition: opacity 0.15s; } transition: opacity 0.15s; }
.bookslm-msg:hover .bookslm-copy-btn { opacity: 1; } .bookslm-msg.user .bookslm-msg-actions { justify-content: flex-end; }
.bookslm-copy-btn:hover { color: var(--accent); border-color: var(--accent); } .bookslm-msg:hover .bookslm-msg-actions,
.bookslm-bubble { max-width: 85%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; word-wrap: break-word; } .bookslm-msg-actions:focus-within { opacity: 1; }
.bookslm-bubble.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; } .bookslm-msg-action { display: inline-flex; align-items: center; gap: 5px;
.bookslm-bubble.assistant { align-self: flex-start; background: var(--surface2); color: var(--text-primary); border-bottom-left-radius: 4px; } font-size: 11px; padding: 3px 9px; border-radius: 6px; border: 1px solid transparent;
background: transparent; color: var(--text-secondary); cursor: pointer; }
.bookslm-msg-action:hover { background: var(--surface2); color: var(--text-primary);
border-color: var(--border); }
.bookslm-bubble { max-width: 85%; padding: 10px 14px; border-radius: 18px; font-size: 14px; line-height: 1.5; word-wrap: break-word; }
.bookslm-bubble.user { align-self: flex-end; background: var(--surface2); color: var(--text-primary); border-bottom-right-radius: 6px; }
.bookslm-bubble.assistant { align-self: flex-start; background: transparent; padding: 0; width: 100%; max-width: 100%; color: var(--text-primary); }
.bookslm-bubble.assistant code { background: rgba(0,0,0,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.9em; } .bookslm-bubble.assistant code { background: rgba(0,0,0,0.2); padding: 1px 4px; border-radius: 3px; font-size: 0.9em; }
.bookslm-bubble.assistant pre { background: rgba(0,0,0,0.3); padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; } .bookslm-bubble.assistant pre { background: rgba(0,0,0,0.3); padding: 10px; border-radius: 6px; overflow-x: auto; margin: 8px 0; }
.bookslm-sources { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; } .bookslm-sources { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
@@ -9484,7 +9492,13 @@ body.popup-mode .content-area {
/* Agent-mode toggle in the header. */ /* Agent-mode toggle in the header. */
.bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); } .bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); }
/* Tool-call trace shown above an assistant answer (agent mode). */ /* Tool-call trace shown above an assistant answer (agent mode). */
.bookslm-tool-trace { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; } /* Agent tool calls collapse into a discreet “N steps” line (Notion-style). */
.bookslm-tool-trace { margin: 0 0 8px; }
.bookslm-tool-trace summary { font-size: 11px; color: var(--text-secondary);
cursor: pointer; user-select: none; list-style: none; }
.bookslm-tool-trace summary::before { content: '> '; }
.bookslm-tool-trace[open] summary { margin-bottom: 4px; }
.bookslm-tool-trace .bookslm-tool-line { padding-left: 12px; }
.bookslm-tool-line { font-size: 11px; color: var(--text-secondary); } .bookslm-tool-line { font-size: 11px; color: var(--text-secondary); }
.bookslm-tool-line.failed { color: #f87171; } .bookslm-tool-line.failed { color: #f87171; }
/* Mutation confirmation card (two-step propose/apply). */ /* Mutation confirmation card (two-step propose/apply). */
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release. * a separate store. Bumping SW_VERSION invalidates it on every release.
*/ */
const SW_VERSION = 'v8'; const SW_VERSION = 'v9';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`;
+132 -77
View File
@@ -104,6 +104,7 @@ async function main() {
const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href); const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href);
const { BooksLM, MODE } = bookslmMod; const { BooksLM, MODE } = bookslmMod;
const { collectOpenDocuments, documentsSignature } = bookslmMod; const { collectOpenDocuments, documentsSignature } = bookslmMod;
const { state } = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
// ── 1. Rewrite modal resolves with the typed instruction ── // ── 1. Rewrite modal resolves with the typed instruction ──
await test("rewrite modal returns the typed instruction on confirm", async () => { await test("rewrite modal returns the typed instruction on confirm", async () => {
@@ -190,87 +191,141 @@ async function main() {
panel.remove(); panel.remove();
}); });
// ── 4b. Latest turn pinned to the top + provider/model tag + copy buttons ── // ── 4b. Notion-style thread: top-anchored latest post, provider/model tag, action bar ──
await test("latest turn (user post + answer) is pinned to the top of messages", () => { await test("thread renders chronologically (user post above its answer)", () => {
const b = new BooksLM(); const b = new BooksLM();
const panel = b._render(); const panel = b._render();
b._panel = panel; b._panel = panel;
document.body.appendChild(panel); document.body.appendChild(panel);
b._messages = [ b._messages = [
{ role: "user", content: "ancien" }, { role: "user", content: "ancien" },
{ role: "assistant", content: "ancienne réponse" }, { role: "assistant", content: "ancienne réponse" },
{ role: "user", content: "Nouveau sujet" }, { role: "user", content: "Nouveau sujet" },
{ role: "assistant", content: "Nouvelle réponse", provider: "deepseek", model: "deepseek-chat" }, { role: "assistant", content: "Nouvelle réponse", provider: "deepseek", model: "deepseek-chat" },
]; ];
b._renderMessages(); b._renderMessages();
const msgs = panel.querySelectorAll(".bookslm-msg"); const roles = [...panel.querySelectorAll(".bookslm-messages > .bookslm-msg")].map((el) =>
// Both bubbles of the latest turn carry the pinning class... el.classList.contains("user") ? "user" : "assistant");
assert.ok(msgs[2].classList.contains("latest"), "latest user pinned"); assert.deepEqual(roles, ["user", "assistant", "user", "assistant"], "chronological order kept");
assert.ok(msgs[3].classList.contains("latest"), "latest assistant pinned"); // No background bubble on the assistant answer (Notion-style full-width text).
assert.ok(!msgs[0].classList.contains("latest"), "older user not pinned"); const aBubble = panel.querySelector(".bookslm-msg.assistant .bookslm-bubble");
assert.ok(!msgs[1].classList.contains("latest"), "older assistant not pinned"); assert.ok(aBubble.classList.contains("assistant"), "assistant bubble class");
panel.remove(); panel.remove();
}); });
await test("assistant answer shows a discreet provider/model tag", () => { await test("sending anchors the latest user post to the top of the thread", async () => {
const b = new BooksLM(); const b = new BooksLM();
const panel = b._render(); const panel = b._render();
b._panel = panel; b._panel = panel;
document.body.appendChild(panel); document.body.appendChild(panel);
b._messages = [ b._messages = [
{ role: "user", content: "Bonjour" }, { role: "user", content: "ancien" },
{ role: "assistant", content: "Réponse", provider: "deepseek", model: "deepseek-v3" }, { role: "assistant", content: "ancienne réponse" },
]; { role: "user", content: "Dernier sujet" },
b._renderMessages(); ];
const meta = panel.querySelector(".bookslm-msg.assistant.latest .bookslm-msg-meta"); b._renderMessages();
assert.ok(meta, "provider/model meta line rendered"); let anchored = null;
assert.equal(meta.textContent, "deepseek · deepseek-v3"); const users = panel.querySelectorAll(".bookslm-msg.user");
panel.remove(); const last = users[users.length - 1];
}); last.scrollIntoView = (opts) => { anchored = opts; };
b._anchorLatestUser();
await sleep(10); // requestAnimationFrame shim → setTimeout(0)
assert.ok(anchored, "scrollIntoView called on the latest user post");
assert.equal(anchored.block, "start");
panel.remove();
});
await test("both user and assistant blocks expose a copy button", () => { await test("assistant answer shows a discreet provider/model tag", () => {
const b = new BooksLM(); const b = new BooksLM();
const panel = b._render(); const panel = b._render();
b._panel = panel; b._panel = panel;
document.body.appendChild(panel); document.body.appendChild(panel);
b._messages = [ b._messages = [
{ role: "user", content: "Question copiable" }, { role: "user", content: "Bonjour" },
{ role: "assistant", content: "Réponse copiable", provider: "deepseek", model: "m" }, { role: "assistant", content: "Réponse", provider: "deepseek", model: "deepseek-v3" },
]; ];
b._renderMessages(); b._renderMessages();
const wrapped = panel.querySelectorAll(".bookslm-msg"); const meta = panel.querySelector(".bookslm-msg.assistant .bookslm-msg-meta");
assert.equal(wrapped.length, 2); assert.ok(meta, "provider/model meta line rendered");
// Both wraps carry a copy button. assert.equal(meta.textContent, "deepseek · deepseek-v3");
assert.ok(panel.querySelector(".bookslm-msg.user .bookslm-copy-btn"), "user copy button"); panel.remove();
assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-copy-btn"), "assistant copy button"); });
panel.remove();
});
await test("copy button copies the raw message text to the clipboard", async () => { await test("action bar: user has copy, assistant has copy + add", () => {
const b = new BooksLM(); const b = new BooksLM();
const panel = b._render(); const panel = b._render();
b._panel = panel; b._panel = panel;
document.body.appendChild(panel); document.body.appendChild(panel);
b._messages = [{ role: "user", content: "texte à copier" }]; b._messages = [
b._renderMessages(); { role: "user", content: "Question copiable" },
const copied = []; { role: "assistant", content: "Réponse copiable", provider: "deepseek", model: "m" },
b._copyText = (t) => copied.push(t); ];
const btn = panel.querySelector(".bookslm-msg.user .bookslm-copy-btn"); b._renderMessages();
assert.ok(btn, "copy button present"); assert.ok(panel.querySelector(".bookslm-msg.user .bookslm-msg-action-copy"), "user copy action");
btn.click(); assert.ok(!panel.querySelector(".bookslm-msg.user .bookslm-msg-action-insert"), "no add on user post");
assert.deepEqual(copied, ["texte à copier"], "copy handler receives raw content"); assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-copy"), "assistant copy action");
panel.remove(); assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-insert"), "assistant add action");
}); panel.remove();
});
await test("empty user content renders no copy button", () => { await test("copy action copies the raw message text to the clipboard", async () => {
const b = new BooksLM(); const b = new BooksLM();
const panel = b._render(); const panel = b._render();
b._panel = panel; b._panel = panel;
b._messages = [{ role: "user", content: " " }]; document.body.appendChild(panel);
b._renderMessages(); b._messages = [{ role: "user", content: "texte à copier" }];
assert.equal(panel.querySelectorAll(".bookslm-copy-btn").length, 0); b._renderMessages();
panel.remove(); const copied = [];
}); b._copyText = (t) => copied.push(t);
const btn = panel.querySelector(".bookslm-msg.user .bookslm-msg-action-copy");
assert.ok(btn, "copy button present");
btn.click();
assert.deepEqual(copied, ["texte à copier"], "copy handler receives raw content");
panel.remove();
});
await test("add action inserts the answer at the editor cursor", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
document.body.appendChild(panel);
b._messages = [{ role: "assistant", content: "Réponse à insérer" }];
b._renderMessages();
const dispatched = [];
state.editorView = {
state: { selection: { main: { to: 7 } } },
dispatch: (spec) => dispatched.push(spec),
focus: () => {},
};
panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-insert").click();
assert.equal(dispatched.length, 1, "editor dispatch called");
assert.ok(dispatched[0].changes.insert.includes("Réponse à insérer"));
state.editorView = null;
panel.remove();
});
await test("empty content renders no action bar", () => {
const b = new BooksLM();
const panel = b._render();
b._panel = panel;
b._messages = [{ role: "user", content: " " }];
b._renderMessages();
assert.equal(panel.querySelectorAll(".bookslm-msg-actions").length, 0);
panel.remove();
});
await test("agent tool calls collapse into a discreet steps block", () => {
const b = new BooksLM();
const trace = b._renderToolActivity([
{ name: "read_file", ok: true },
{ name: "search", ok: true },
]);
assert.equal(trace.tagName, "DETAILS", "collapsible details element");
const summary = trace.querySelector("summary");
assert.ok(summary, "summary present");
assert.ok(summary.textContent.trim().length > 0, "summary shows the steps label");
assert.equal(trace.querySelectorAll(".bookslm-tool-line").length, 2, "each step listed inside");
});
// ── 5. Source guards: no window.prompt() and authenticated BooksLM ── // ── 5. Source guards: no window.prompt() and authenticated BooksLM ──
await test("ai.js no longer uses window.prompt()", async () => { await test("ai.js no longer uses window.prompt()", async () => {