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
+116 -44
View File
@@ -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();
}