feat(assistant): #91 refonte Notion de la zone de discussion — question ancrée en haut, fil chronologique, étapes repliables, barre Copier/Ajouter
This commit is contained in:
+11
-8
@@ -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
|
||||
alignée sur le même ordre markdown (groupes nav / édition / export / partage).
|
||||
Tests : `tests/frontend/toolbar-order.test.mjs` (9).
|
||||
- **#91 Assistant IA — fenêtre de résultats améliorée** : un message soumis et la
|
||||
réponse de l'assistant **en cours d'écriture** sont épinglés en haut de la fenêtre
|
||||
de messages (`.bookslm-msg.latest { order: -1 }`), pour toujours voir le résultat
|
||||
que l'assistant rédige ; un libellé **discret** au-dessus de chaque réponse rapporte
|
||||
le fournisseur et le modèle réellement utilisés (SSE `provider`/`model`) ; un bouton
|
||||
« Copier » sous chaque bloc (utilisateur et assistant) copie le texte brut dans le
|
||||
presse-papiers avec toast de confirmation. Fiche :
|
||||
- **#91 Assistant IA — zone de discussion façon Notion** : le fil de discussion est
|
||||
reformaté (messages chronologiques, post utilisateur en bulle alignée à droite,
|
||||
réponse assistant pleine largeur sans fond, appels d'outils agent repliés en
|
||||
bloc « N étapes ») ; après l'envoi, la **question est ancrée en haut** de la zone
|
||||
visible (`scrollIntoView` `block:start` + `scroll-margin-top`) et la réponse se
|
||||
diffuse en dessous — le streaming ne provoque plus de saut de scroll ; un libellé
|
||||
**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)
|
||||
Tests : `tests/frontend/ai.test.mjs` (71).
|
||||
Tests : `tests/frontend/ai.test.mjs` (74).
|
||||
|
||||
### Corrigé
|
||||
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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é
|
||||
> **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)
|
||||
|
||||
- **Description :** Trois améliorations d'expérience dans la fenêtre de résultats de
|
||||
l'assistant IA (`frontend/js/bookslm.js`, `frontend/style.css`) :
|
||||
1. **Post toujours en haut** : dès qu'un message est soumis, ce tour (le post
|
||||
utilisateur **et** la réponse de l'assistant en cours d'écriture) est épinglé en
|
||||
haut de la fenêtre de messages, afin que le résultat écrit par l'assistant reste
|
||||
toujours visible — même après re-render successifs et auto-scroll.
|
||||
2. **Fournisseur & modèle discrets** : juste au-dessus du bloc de texte rédigé par
|
||||
l'assistant, un libellé discret rapporte le fournisseur et le modèle réellement
|
||||
utilisés (fournis par le backend via le flux SSE `{token, provider, model}`).
|
||||
3. **Bouton « Copier »** : sous le bloc soumis par l'utilisateur ainsi que sous le
|
||||
bloc rédigé par l'assistant, un bouton copie le texte brut de ce bloc dans le
|
||||
presse-papiers (avec toast de confirmation).
|
||||
- **Description :** Refonte de la présentation de la fenêtre de résultats de
|
||||
l'assistant IA (`frontend/js/bookslm.js`, `frontend/style.css`) sur le modèle de
|
||||
l'assistant Notion :
|
||||
1. **Ancrage en haut** : après l'envoi, le post de l'utilisateur est amené tout en
|
||||
haut de la zone visible (`scrollIntoView({ block: 'start' })` +
|
||||
`scroll-margin-top`) ; la réponse se diffuse **en dessous**, lisible sans
|
||||
défilement. Plus de scroll-into-bottom ni d'astuce `order: -1`.
|
||||
2. **Fil Notion-style** : messages en ordre chronologique ; post utilisateur =
|
||||
bulle arrondie alignée à droite (max 80 %) ; réponse assistant = texte pleine
|
||||
largeur **sans bulle de fond** ; les appels d'outils du mode agent se replient
|
||||
dans un bloc discret « N étapes » (`<details>`).
|
||||
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é
|
||||
- [x] **A1.** Chaque message est enveloppé dans un bloc `.bookslm-msg` ; le conteneur
|
||||
`.bookslm-messages` est un flex column et les blocs du **dernier tour** (depuis le
|
||||
dernier message utilisateur inclus) portent `.latest` → `order: -1`, les épinglant
|
||||
en haut de la fenêtre.
|
||||
- [x] **A2.** La détection du dernier tour se fait au rendu (`_renderMessages()`) en
|
||||
recherchant le dernier message `role === 'user'` ; tout ce qui suit (sa réponse, ses
|
||||
actions/cartes) reste groupé au-dessus de l'historique antérieur.
|
||||
- [x] **A3.** L'auto-scroll vers le bas (`scrollTop = scrollHeight`) conserve le dernier
|
||||
tour en haut, hors d'atteinte du contenu précédent.
|
||||
## A. Positionnement automatique (ancre en haut) — ✅ livré
|
||||
- [x] **A1.** `_renderMessages({ anchor: true })` appelé à l'envoi du message, à
|
||||
l'ouverture d'un contexte, au rechargement d'une session et à la reprise après
|
||||
confirmation agent : cible = dernier `.bookslm-msg.user`, `scrollIntoView`
|
||||
fluide `block: 'start'`, repli calcul `scrollTop += delta` si indisponible.
|
||||
- [x] **A2.** Re-rendus de streaming : la position de scroll est **préservée**
|
||||
(plus de `scrollTop = scrollHeight`) — la réponse s'allonge sous la question
|
||||
sans jamais déplacer la vue.
|
||||
- [x] **A3.** Marge supérieure : `scroll-margin-top: 14px` sur `.bookslm-msg`
|
||||
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é
|
||||
- [x] **B1.** `_streamResponse()` capture `data.provider` et `data.model` du flux SSE
|
||||
(déjà émis par `/chat` et `/agent`) sur le message assistant.
|
||||
- [x] **B2.** Au rendu d'un message assistant, `.bookslm-msg-meta` affiche
|
||||
« `fournisseur · modèle` » au-dessus du bloc (visible uniquement si l'info est reçue).
|
||||
- [x] **B3.** Style discret (10px, `var(--text-secondary)`), non sélectionnable.
|
||||
## B. Fil chronologique Notion-style — ✅ livré
|
||||
- [x] **B1.** Ordre naturel (user puis assistant, empilés, `gap: 24px`).
|
||||
- [x] **B2.** Utilisateur : bulle `--surface2` alignée à droite, `max-width: 80%`,
|
||||
coins arrondis 18px.
|
||||
- [x] **B3.** Assistant : `width: 100%`, pas de fond ni de padding de bulle —
|
||||
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é
|
||||
- [x] **C1.** `_appendCopyButton()` ajoute un bouton `.bookslm-copy-btn` sous chaque
|
||||
bloc non vide (utilisateur et assistant) avec le texte i18n `bookslm.copy`.
|
||||
- [x] **C2.** `_copyText()` copie le **texte brut** du message (`navigator.clipboard`,
|
||||
repli `_fallbackCopy`) et affiche le toast `bookslm.copied`.
|
||||
- [x] **C3.** Le bouton est masqué par défaut (opacité 0) et révélé au survol du bloc
|
||||
`.bookslm-msg`.
|
||||
## C. Fournisseur & modèle discrets — ✅ livré
|
||||
- [x] **C1.** `_streamResponse()` capture `data.provider` / `data.model` du flux SSE
|
||||
(déjà émis par `/chat` et `/agent`) sur le message assistant.
|
||||
- [x] **C2.** `.bookslm-msg-meta` au-dessus du bloc assistant :
|
||||
« fournisseur · modèle » (persisté avec la session, visible au rechargement).
|
||||
|
||||
## D. Tests & documentation — ✅ livré
|
||||
- [x] **D1.** `tests/frontend/ai.test.mjs` : dernier tour épinglé, tag fournisseur/modèle
|
||||
rendu, boutons copier présents sur les deux blocs, copie du texte brut, absence de
|
||||
bouton pour un contenu vide.
|
||||
- [x] **D2.** i18n FR/EN : `bookslm.copied`.
|
||||
- [x] **D3.** CHANGELOG + Roadmap.
|
||||
## D. Barre d'actions — ✅ livré
|
||||
- [x] **D1.** `_appendActionBar()` sous chaque bloc non vide : bouton Copier pour
|
||||
les deux rôles ; bouton Ajouter pour l'assistant uniquement.
|
||||
- [x] **D2.** Copier : texte brut du message (`navigator.clipboard`, repli
|
||||
`execCommand`) + toast `bookslm.copied`.
|
||||
- [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
|
||||
- Le tag provient du **backend réel** (`provider`/`model` du SSE) et reflète donc le
|
||||
modèle effectif même lorsque l'utilisateur conserve la sélection « par défaut ».
|
||||
- Le copier utilise le texte brut (pas le HTML rendu) — cohérent avec l'export de
|
||||
conversation et évite d'embarquer des liens/balises de rendu.
|
||||
## E. Tests & documentation — ✅ livré
|
||||
- [x] **E1.** `tests/frontend/ai.test.mjs` : ordre chronologique, ancrage
|
||||
`scrollIntoView block:start`, tag provider/modèle, barre d'actions par rôle,
|
||||
copie du texte brut, insertion éditeur (mock `state.editorView`), bloc
|
||||
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
@@ -215,7 +215,7 @@ class BooksLM {
|
||||
this._setHidden(false);
|
||||
this._updateHeader();
|
||||
this._updateStatus();
|
||||
this._renderMessages();
|
||||
this._renderMessages({ anchor: true });
|
||||
this._showSuggestions();
|
||||
this._renderAttachments();
|
||||
this._closeHistoryMenu();
|
||||
@@ -1492,7 +1492,7 @@ class BooksLM {
|
||||
|
||||
_pushAssistantNote(markdown) {
|
||||
this._messages.push({ role: 'assistant', content: markdown, sources: [], toolCalls: [], confirmation: null });
|
||||
this._renderMessages();
|
||||
this._renderMessages({ anchor: true });
|
||||
this._saveHistory();
|
||||
}
|
||||
|
||||
@@ -1777,42 +1777,62 @@ class BooksLM {
|
||||
}
|
||||
}
|
||||
|
||||
_renderMessages() {
|
||||
_renderMessages(opts = {}) {
|
||||
if (!this._panel) return;
|
||||
const container = this._panel.querySelector('.bookslm-messages');
|
||||
if (!container) return;
|
||||
const prevTop = container.scrollTop;
|
||||
container.innerHTML = '';
|
||||
|
||||
// The "latest turn" — the most recent user post and, when present, its
|
||||
// assistant answer — is pinned to the top of the window. Everything from
|
||||
// the last user message onward stays in view so the submitted post and
|
||||
// the answer being written are always visible.
|
||||
let lastUserIdx = -1;
|
||||
for (let i = this._messages.length - 1; i >= 0; i--) {
|
||||
if (this._messages[i].role === 'user') { lastUserIdx = i; break; }
|
||||
// Notion-style thread: chronological order, the user post anchored at the
|
||||
// top of the visible area (never scroll-to-bottom — the answer streams
|
||||
// below the question). Streaming re-renders preserve the scroll position
|
||||
// so the reader is never jumped around.
|
||||
for (const msg of this._messages) {
|
||||
container.appendChild(this._chatBubble(msg));
|
||||
}
|
||||
|
||||
for (let i = 0; i < this._messages.length; i++) {
|
||||
const msg = this._messages[i];
|
||||
const bubble = this._chatBubble(msg, i >= lastUserIdx && lastUserIdx >= 0);
|
||||
container.appendChild(bubble);
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
if (opts.anchor) this._anchorLatestUser();
|
||||
else container.scrollTop = prevTop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single chat bubble (shared by user and assistant messages).
|
||||
*
|
||||
* Each bubble is wrapped in a `bookslm-msg` block. Bubbles flagged as part
|
||||
* 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.
|
||||
* 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),
|
||||
* reproducing Notion-AI's conversation anchoring.
|
||||
*/
|
||||
_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');
|
||||
wrap.className = 'bookslm-msg ' + msg.role + (isLatest ? ' latest' : '');
|
||||
wrap.className = 'bookslm-msg ' + msg.role;
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
// Discreet provider/model tag above the answer. Uses the actual engine
|
||||
@@ -1826,13 +1846,15 @@ class BooksLM {
|
||||
metaLine.title = label;
|
||||
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');
|
||||
bubble.className = 'bookslm-bubble assistant';
|
||||
const { text, actions } = this._extractActions(msg.content || '');
|
||||
bubble.innerHTML = this._renderMarkdown(text);
|
||||
if (msg.toolCalls && msg.toolCalls.length) {
|
||||
bubble.insertBefore(this._renderToolActivity(msg.toolCalls), bubble.firstChild);
|
||||
}
|
||||
if (actions.length) {
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'bookslm-actions';
|
||||
@@ -1863,26 +1885,71 @@ class BooksLM {
|
||||
wrap.appendChild(bubble);
|
||||
}
|
||||
|
||||
// Copy button under the message block.
|
||||
this._appendCopyButton(wrap, msg);
|
||||
// Notion-style action bar revealed on hover of the message block.
|
||||
this._appendActionBar(wrap, msg);
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/** Copy the raw message text to the clipboard (user or assistant block). */
|
||||
_appendCopyButton(wrap, msg) {
|
||||
/** Hover action bar under a message: Copy (both roles) + Add (assistant). */
|
||||
_appendActionBar(wrap, msg) {
|
||||
const content = String(msg.content || '').trim();
|
||||
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');
|
||||
btn.type = 'button';
|
||||
btn.className = 'bookslm-copy-btn';
|
||||
btn.textContent = t('bookslm.copy');
|
||||
btn.title = t('bookslm.copy');
|
||||
btn.setAttribute('aria-label', t('bookslm.copy'));
|
||||
btn.addEventListener('click', () => {
|
||||
this._copyText(content);
|
||||
});
|
||||
wrap.appendChild(btn);
|
||||
btn.className = `bookslm-msg-action bookslm-msg-action-${name}`;
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
const icon = document.createElement('span');
|
||||
icon.setAttribute('data-lucide', name === 'copy' ? 'copy' : 'corner-down-left');
|
||||
icon.style.cssText = 'width:12px;height:12px';
|
||||
btn.appendChild(icon);
|
||||
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) {
|
||||
@@ -1975,9 +2042,13 @@ class BooksLM {
|
||||
|
||||
// ── Tool activity & confirmations (agent mode) ──────────────────────
|
||||
|
||||
/** Agent tool calls collapse into a discreet “N steps” line (Notion-style). */
|
||||
_renderToolActivity(toolCalls) {
|
||||
const wrap = document.createElement('div');
|
||||
const wrap = document.createElement('details');
|
||||
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) {
|
||||
const line = document.createElement('div');
|
||||
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.
|
||||
msg.confirmation = null;
|
||||
this._messages.push(continuation);
|
||||
this._renderMessages({ anchor: true });
|
||||
await this._streamResponse(resp, continuation, payload);
|
||||
} finally {
|
||||
conf._applying = false;
|
||||
@@ -2178,7 +2250,7 @@ class BooksLM {
|
||||
.map((m) => ({ role: m.role, content: m.content }));
|
||||
|
||||
this._messages.push({ role: 'user', content: text });
|
||||
this._renderMessages();
|
||||
this._renderMessages({ anchor: true });
|
||||
|
||||
let provider = null;
|
||||
let model = null;
|
||||
@@ -2764,7 +2836,7 @@ class BooksLM {
|
||||
this._messages = Array.isArray(session.messages) ? session.messages : [];
|
||||
this._closeHistoryMenu();
|
||||
if (this._panel) {
|
||||
this._renderMessages();
|
||||
this._renderMessages({ anchor: true });
|
||||
this._showSuggestions();
|
||||
this._renderHistoryMenu();
|
||||
}
|
||||
|
||||
+1876
-1871
File diff suppressed because it is too large
Load Diff
+1876
-1871
File diff suppressed because it is too large
Load Diff
+30
-16
@@ -9434,22 +9434,30 @@ body.popup-mode .content-area {
|
||||
.bookslm-activity.done .bookslm-activity-spinner,
|
||||
.bookslm-activity.error .bookslm-activity-spinner { animation: none; border-top-color: currentColor; }
|
||||
@keyframes bookslm-spin { to { transform: rotate(360deg); } }
|
||||
.bookslm-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
/* The newest turn (the assistant's answer being written) is pinned to the
|
||||
very top of the messages window via flex `order`, so a submitted post and
|
||||
its live result stay visible regardless of auto-scroll and prior history. */
|
||||
.bookslm-msg { display: flex; flex-direction: column; gap: 4px; }
|
||||
.bookslm-msg.latest { order: -1; }
|
||||
.bookslm-msg-meta { font-size: 10px; color: var(--text-secondary); opacity: 0.85;
|
||||
/* Notion-style AI thread: only this zone scrolls; entries stack vertically
|
||||
with generous spacing. The latest user post is anchored to the top of the
|
||||
visible area on send (scrollIntoView block:start + scroll-margin). */
|
||||
.bookslm-messages { flex: 1; overflow-y: auto; padding: 20px 16px; display: flex;
|
||||
flex-direction: column; gap: 24px; }
|
||||
.bookslm-msg { display: flex; flex-direction: column; gap: 4px; scroll-margin-top: 14px; }
|
||||
.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; }
|
||||
.bookslm-copy-btn { align-self: flex-end; font-size: 10px; padding: 2px 8px;
|
||||
border-radius: 6px; border: 1px solid var(--border); background: var(--surface);
|
||||
color: var(--text-secondary); cursor: pointer; opacity: 0; transition: opacity 0.15s; }
|
||||
.bookslm-msg:hover .bookslm-copy-btn { opacity: 1; }
|
||||
.bookslm-copy-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.bookslm-bubble { max-width: 85%; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.5; word-wrap: break-word; }
|
||||
.bookslm-bubble.user { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
|
||||
.bookslm-bubble.assistant { align-self: flex-start; background: var(--surface2); color: var(--text-primary); border-bottom-left-radius: 4px; }
|
||||
/* Hover-revealed action bar (Copier / Ajouter). */
|
||||
.bookslm-msg-actions { display: flex; gap: 6px; margin-top: 4px; opacity: 0;
|
||||
transition: opacity 0.15s; }
|
||||
.bookslm-msg.user .bookslm-msg-actions { justify-content: flex-end; }
|
||||
.bookslm-msg:hover .bookslm-msg-actions,
|
||||
.bookslm-msg-actions:focus-within { opacity: 1; }
|
||||
.bookslm-msg-action { display: inline-flex; align-items: center; gap: 5px;
|
||||
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 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; }
|
||||
@@ -9484,7 +9492,13 @@ body.popup-mode .content-area {
|
||||
/* Agent-mode toggle in the header. */
|
||||
.bookslm-header button.bookslm-btn-agent.active { color: var(--accent); background: var(--surface2); }
|
||||
/* 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.failed { color: #f87171; }
|
||||
/* Mutation confirmation card (two-step propose/apply). */
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* 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 RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
|
||||
+132
-77
@@ -104,6 +104,7 @@ async function main() {
|
||||
const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href);
|
||||
const { BooksLM, MODE } = 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 ──
|
||||
await test("rewrite modal returns the typed instruction on confirm", async () => {
|
||||
@@ -190,87 +191,141 @@ async function main() {
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
// ── 4b. Latest turn pinned to the top + provider/model tag + copy buttons ──
|
||||
await test("latest turn (user post + answer) is pinned to the top of messages", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [
|
||||
{ role: "user", content: "ancien" },
|
||||
{ role: "assistant", content: "ancienne réponse" },
|
||||
{ role: "user", content: "Nouveau sujet" },
|
||||
{ role: "assistant", content: "Nouvelle réponse", provider: "deepseek", model: "deepseek-chat" },
|
||||
];
|
||||
b._renderMessages();
|
||||
const msgs = panel.querySelectorAll(".bookslm-msg");
|
||||
// Both bubbles of the latest turn carry the pinning class...
|
||||
assert.ok(msgs[2].classList.contains("latest"), "latest user pinned");
|
||||
assert.ok(msgs[3].classList.contains("latest"), "latest assistant pinned");
|
||||
assert.ok(!msgs[0].classList.contains("latest"), "older user not pinned");
|
||||
assert.ok(!msgs[1].classList.contains("latest"), "older assistant not pinned");
|
||||
panel.remove();
|
||||
});
|
||||
// ── 4b. Notion-style thread: top-anchored latest post, provider/model tag, action bar ──
|
||||
await test("thread renders chronologically (user post above its answer)", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [
|
||||
{ role: "user", content: "ancien" },
|
||||
{ role: "assistant", content: "ancienne réponse" },
|
||||
{ role: "user", content: "Nouveau sujet" },
|
||||
{ role: "assistant", content: "Nouvelle réponse", provider: "deepseek", model: "deepseek-chat" },
|
||||
];
|
||||
b._renderMessages();
|
||||
const roles = [...panel.querySelectorAll(".bookslm-messages > .bookslm-msg")].map((el) =>
|
||||
el.classList.contains("user") ? "user" : "assistant");
|
||||
assert.deepEqual(roles, ["user", "assistant", "user", "assistant"], "chronological order kept");
|
||||
// No background bubble on the assistant answer (Notion-style full-width text).
|
||||
const aBubble = panel.querySelector(".bookslm-msg.assistant .bookslm-bubble");
|
||||
assert.ok(aBubble.classList.contains("assistant"), "assistant bubble class");
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
await test("assistant answer shows a discreet provider/model tag", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [
|
||||
{ role: "user", content: "Bonjour" },
|
||||
{ role: "assistant", content: "Réponse", provider: "deepseek", model: "deepseek-v3" },
|
||||
];
|
||||
b._renderMessages();
|
||||
const meta = panel.querySelector(".bookslm-msg.assistant.latest .bookslm-msg-meta");
|
||||
assert.ok(meta, "provider/model meta line rendered");
|
||||
assert.equal(meta.textContent, "deepseek · deepseek-v3");
|
||||
panel.remove();
|
||||
});
|
||||
await test("sending anchors the latest user post to the top of the thread", async () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [
|
||||
{ role: "user", content: "ancien" },
|
||||
{ role: "assistant", content: "ancienne réponse" },
|
||||
{ role: "user", content: "Dernier sujet" },
|
||||
];
|
||||
b._renderMessages();
|
||||
let anchored = null;
|
||||
const users = panel.querySelectorAll(".bookslm-msg.user");
|
||||
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", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [
|
||||
{ role: "user", content: "Question copiable" },
|
||||
{ role: "assistant", content: "Réponse copiable", provider: "deepseek", model: "m" },
|
||||
];
|
||||
b._renderMessages();
|
||||
const wrapped = panel.querySelectorAll(".bookslm-msg");
|
||||
assert.equal(wrapped.length, 2);
|
||||
// Both wraps carry a copy button.
|
||||
assert.ok(panel.querySelector(".bookslm-msg.user .bookslm-copy-btn"), "user copy button");
|
||||
assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-copy-btn"), "assistant copy button");
|
||||
panel.remove();
|
||||
});
|
||||
await test("assistant answer shows a discreet provider/model tag", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [
|
||||
{ role: "user", content: "Bonjour" },
|
||||
{ role: "assistant", content: "Réponse", provider: "deepseek", model: "deepseek-v3" },
|
||||
];
|
||||
b._renderMessages();
|
||||
const meta = panel.querySelector(".bookslm-msg.assistant .bookslm-msg-meta");
|
||||
assert.ok(meta, "provider/model meta line rendered");
|
||||
assert.equal(meta.textContent, "deepseek · deepseek-v3");
|
||||
panel.remove();
|
||||
});
|
||||
|
||||
await test("copy button copies the raw message text to the clipboard", async () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [{ role: "user", content: "texte à copier" }];
|
||||
b._renderMessages();
|
||||
const copied = [];
|
||||
b._copyText = (t) => copied.push(t);
|
||||
const btn = panel.querySelector(".bookslm-msg.user .bookslm-copy-btn");
|
||||
assert.ok(btn, "copy button present");
|
||||
btn.click();
|
||||
assert.deepEqual(copied, ["texte à copier"], "copy handler receives raw content");
|
||||
panel.remove();
|
||||
});
|
||||
await test("action bar: user has copy, assistant has copy + add", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [
|
||||
{ role: "user", content: "Question copiable" },
|
||||
{ role: "assistant", content: "Réponse copiable", provider: "deepseek", model: "m" },
|
||||
];
|
||||
b._renderMessages();
|
||||
assert.ok(panel.querySelector(".bookslm-msg.user .bookslm-msg-action-copy"), "user copy action");
|
||||
assert.ok(!panel.querySelector(".bookslm-msg.user .bookslm-msg-action-insert"), "no add on user post");
|
||||
assert.ok(panel.querySelector(".bookslm-msg.assistant .bookslm-msg-action-copy"), "assistant copy action");
|
||||
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", () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
b._messages = [{ role: "user", content: " " }];
|
||||
b._renderMessages();
|
||||
assert.equal(panel.querySelectorAll(".bookslm-copy-btn").length, 0);
|
||||
panel.remove();
|
||||
});
|
||||
await test("copy action copies the raw message text to the clipboard", async () => {
|
||||
const b = new BooksLM();
|
||||
const panel = b._render();
|
||||
b._panel = panel;
|
||||
document.body.appendChild(panel);
|
||||
b._messages = [{ role: "user", content: "texte à copier" }];
|
||||
b._renderMessages();
|
||||
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 ──
|
||||
await test("ai.js no longer uses window.prompt()", async () => {
|
||||
|
||||
Reference in New Issue
Block a user