feat(assistant): #91 etapes enrichies (sous-sections Reflechon/Sources, chevrons, indicateur anime, suppression barre de chargement)
CI / lint (push) Successful in 1m23s
CI / security (push) Successful in 58s
CI / test (push) Successful in 2m43s
CI / build (push) Successful in 55s
CI / e2e (push) Successful in 11m8s

This commit is contained in:
2026-09-15 22:53:15 -04:00
parent 431fb5626d
commit f630771150
13 changed files with 477 additions and 80 deletions
+153 -38
View File
@@ -835,10 +835,6 @@ class BooksLM {
<div class="bookslm-status"></div>
<div class="bookslm-suggestions"></div>
<div class="bookslm-messages"></div>
<div class="bookslm-activity hidden" role="status" aria-live="polite">
<span class="bookslm-activity-spinner" aria-hidden="true"></span>
<span class="bookslm-activity-text"></span>
</div>
<div class="bookslm-attachments"></div>
<div class="bookslm-menu-layer">
<div class="bookslm-command-menu hidden" role="listbox" aria-label="${t('ai.commands')}"></div>
@@ -1698,24 +1694,18 @@ class BooksLM {
}
/**
* Show the assistant activity state in the panel (visual workflow).
* Record the assistant activity state.
*
* The dedicated loading bar above the composer was removed: in agent mode
* the animated indicator now lives in the steps header, and in plain chat
* the pending answer shows an inline indicator. Kept as the single entry
* point for the streaming code paths (state is informational only).
* @param {'idle'|'working'|'done'|'error'} kind
* @param {string} [text]
*/
_setActivity(kind, text) {
if (!this._panel) return;
if (this._activityHideTimer) {
clearTimeout(this._activityHideTimer);
this._activityHideTimer = null;
}
const el = this._panel.querySelector('.bookslm-activity');
if (!el) return;
const label = el.querySelector('.bookslm-activity-text');
if (label) label.textContent = text || '';
el.classList.toggle('hidden', kind === 'idle');
el.classList.toggle('working', kind === 'working');
el.classList.toggle('done', kind === 'done');
el.classList.toggle('error', kind === 'error');
this._activityKind = kind;
this._activityText = text || '';
}
_updateStatus(data) {
@@ -1904,15 +1894,24 @@ class BooksLM {
metaLine.title = label;
wrap.appendChild(metaLine);
}
// Notion-style: the tool calls collapse into a “N steps” line above the
// Notion-style: the tool calls collapse into a “N étapes” line above the
// answer instead of a visible list.
const running = this._isRunningMessage(msg);
if (msg.toolCalls && msg.toolCalls.length) {
wrap.appendChild(this._renderToolActivity(msg.toolCalls));
wrap.appendChild(this._renderToolActivity(msg.toolCalls, msg, running));
}
const bubble = document.createElement('div');
bubble.className = 'bookslm-bubble assistant';
const { text, actions } = this._extractActions(msg.content || '');
bubble.innerHTML = this._renderMarkdown(text);
// Plain chat has no steps block: the pending answer itself carries the
// animated indicator until the first token arrives.
if (running && !text && !actions.length) {
const pending = document.createElement('div');
pending.className = 'bookslm-pending';
pending.appendChild(this._dots());
bubble.appendChild(pending);
}
if (actions.length) {
const actionsDiv = document.createElement('div');
actionsDiv.className = 'bookslm-actions';
@@ -2111,24 +2110,140 @@ class BooksLM {
return t('ai.tool_call', { name: call.name });
}
/** Agent tool calls collapse into a discreet “N steps” line (Notion-style). */
_renderToolActivity(toolCalls) {
const wrap = document.createElement('details');
wrap.className = 'bookslm-tool-trace';
const summary = document.createElement('summary');
const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
summary.textContent = t(countKey, { 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' : '');
line.textContent = `${call.ok === false ? '⚠' : '·'} ${this._stepText(call)}`;
if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name });
else if (call.step && call.step.params && call.step.params.value) line.title = call.step.params.value;
wrap.appendChild(line);
}
return wrap;
/** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */
_chevron() {
const c = document.createElement('span');
c.className = 'bookslm-chevron';
c.setAttribute('aria-hidden', 'true');
return c;
}
/** True while this very message is the one being produced. */
_isRunningMessage(msg) {
return !!this._isLoading && this._messages[this._messages.length - 1] === msg;
}
/**
* Agent tool calls collapse into a discreet “N étapes” block (Notion-style).
*
* The header carries an animated indicator while the agent works; expanding
* it reveals the chronological steps, where each reasoning note becomes a
* « Réflexion » sub-section and web searches add a clickable sources list.
* Open states live on the message so the re-render of every streamed token
* never collapses what the user expanded.
*/
_renderToolActivity(toolCalls, msg, running) {
const wrap = document.createElement('details');
wrap.className = 'bookslm-tool-trace';
wrap.open = !!(msg && msg._stepsOpen);
const summary = document.createElement('summary');
if (running) {
const dots = this._dots();
dots.classList.add('bookslm-steps-dots');
summary.appendChild(dots);
}
const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
const label = document.createElement('span');
label.className = 'bookslm-steps-label';
label.textContent = t(countKey, { count: toolCalls.length });
summary.appendChild(label);
summary.appendChild(this._chevron());
if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`);
wrap.appendChild(summary);
const body = document.createElement('div');
body.className = 'bookslm-steps-body';
const sources = [];
for (const call of toolCalls) {
// A reasoning note is its own collapsible sub-section, not a line.
if (call.step && call.step.key === 'thought') {
body.appendChild(this._renderThought(call, msg));
continue;
}
const line = document.createElement('div');
line.className = 'bookslm-tool-line' + (call.ok === false ? ' failed' : '');
line.textContent = `${call.ok === false ? '⚠' : '·'} ${this._stepText(call)}`;
if (call.ok === false) line.title = t('ai.tool_call_failed', { name: call.name });
else if (call.step && call.step.params && call.step.params.value) line.title = call.step.params.value;
body.appendChild(line);
if (Array.isArray(call.sources)) {
for (const s of call.sources) {
if (s && s.url) sources.push(s);
}
}
}
if (sources.length) body.appendChild(this._renderSources(sources, msg));
wrap.appendChild(body);
wrap.addEventListener('toggle', () => { if (msg) msg._stepsOpen = wrap.open; });
return wrap;
}
/** Animated three-dot activity indicator (pure CSS, reduced-motion aware). */
_dots() {
const dots = document.createElement('span');
dots.className = 'bookslm-dots';
dots.setAttribute('aria-hidden', 'true');
dots.innerHTML = '<i></i><i></i><i></i>';
return dots;
}
/** One collapsible « Réflexion » block holding the model's reasoning note. */
_renderThought(call, msg) {
const text = (call.step && call.step.params && call.step.params.value) || '';
const box = document.createElement('details');
box.className = 'bookslm-thought';
box.open = !!(msg && msg._thoughtOpen && msg._thoughtOpen[text]);
const summary = document.createElement('summary');
const label = document.createElement('span');
label.className = 'bookslm-thought-label';
label.textContent = t('ai.thought_label');
summary.appendChild(label);
summary.appendChild(this._chevron());
box.appendChild(summary);
const body = document.createElement('div');
body.className = 'bookslm-thought-text';
body.textContent = text;
box.appendChild(body);
box.addEventListener('toggle', () => {
if (!msg) return;
msg._thoughtOpen = msg._thoughtOpen || {};
msg._thoughtOpen[text] = box.open;
});
return box;
}
/** Web sources gathered from the run's searches, as real links. */
_renderSources(list, msg) {
const box = document.createElement('details');
box.className = 'bookslm-web-sources';
// Visible by default: the point of the section is to expose the links.
box.open = !(msg && msg._sourcesOpen === false);
const summary = document.createElement('summary');
const label = document.createElement('span');
label.className = 'bookslm-web-sources-label';
label.textContent = t('ai.sources_label', { count: list.length });
summary.appendChild(label);
summary.appendChild(this._chevron());
box.appendChild(summary);
const ul = document.createElement('ul');
ul.className = 'bookslm-web-sources-list';
for (const s of list) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = s.url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = s.title || s.url;
a.title = s.url;
li.appendChild(a);
ul.appendChild(li);
}
box.appendChild(ul);
box.addEventListener('toggle', () => { if (msg) msg._sourcesOpen = box.open; });
return box;
}
_renderConfirmationCard(msg) {
const conf = msg.confirmation;
@@ -2391,7 +2506,6 @@ class BooksLM {
.map((f) => f.path || f);
}
this._setActivity('done', t('ai.activity_done'));
this._activityHideTimer = setTimeout(() => this._setActivity('idle'), 1500);
} catch (e) {
if (e.name !== 'AbortError') {
assistantMsg.content = `⚠ Error: ${e.message}`;
@@ -2459,6 +2573,7 @@ class BooksLM {
ok: data.ok !== false,
arguments: data.arguments || {},
step: data.step || null,
sources: Array.isArray(data.sources) ? data.sources : [],
});
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
return;
+3
View File
@@ -1784,6 +1784,9 @@
"ai.steps_count_plural": "{count} steps",
"ai.activity_thinking": "Thinking…",
"ai.step.thought": "Thought: {value}",
"ai.thought_label": "Thought",
"ai.sources_label": "Sources ({count})",
"ai.steps_running": "running",
"ai.step.backlinks": "Analyzed backlinks",
"ai.step.backup_diff": "Compared backup: {value}",
"ai.step.backup_restore": "Restored backup: {value}",
+3
View File
@@ -1784,6 +1784,9 @@
"ai.steps_count_plural": "{count} étapes",
"ai.activity_thinking": "Réflexion…",
"ai.step.thought": "Réflexion : {value}",
"ai.thought_label": "Réflexion",
"ai.sources_label": "Sources ({count})",
"ai.steps_running": "exécution en cours",
"ai.step.backlinks": "Backlinks analysés",
"ai.step.backup_diff": "Comparaison de sauvegarde : {value}",
"ai.step.backup_restore": "Sauvegarde restaurée : {value}",
+55 -21
View File
@@ -9421,19 +9421,19 @@ body.popup-mode .content-area {
.bookslm-status .bookslm-context-bar { flex: 1; height: 4px; border-radius: 2px;
background: var(--surface2); overflow: hidden; }
.bookslm-status .bookslm-context-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; }
/* Assistant activity indicator (visual workflow: sending/streaming/tools/done/error). */
.bookslm-activity { display: flex; align-items: center; gap: 8px; padding: 6px 16px;
font-size: 12px; color: var(--text-secondary); border-top: 1px solid var(--border);
background: var(--bg-secondary); }
.bookslm-activity.hidden { display: none; }
.bookslm-activity.error { color: #f87171; }
.bookslm-activity.done { color: var(--success, #3fb950); }
.bookslm-activity-spinner { width: 12px; height: 12px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%; flex-shrink: 0;
animation: bookslm-spin 0.8s linear infinite; }
.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); } }
/* Assistant activity — no loading bar above the composer anymore: in agent
mode the animated dots sit in the steps header (« ●●● 3 étapes ▶ »), in
plain chat they sit in the pending answer. Pure CSS, reduced-motion aware. */
.bookslm-dots { display: inline-flex; align-items: center; gap: 2px; }
.bookslm-dots i { width: 4px; height: 4px; border-radius: 50%; background: currentColor;
opacity: 0.25; animation: bookslm-dot 1.2s ease-in-out infinite; }
.bookslm-dots i:nth-child(2) { animation-delay: 0.15s; }
.bookslm-dots i:nth-child(3) { animation-delay: 0.3s; }
@keyframes bookslm-dot {
0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-2px); }
}
.bookslm-pending { display: flex; align-items: center; height: 20px; color: var(--text-secondary); }
/* 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: the post is anchored flush at the top (block:start,
@@ -9497,16 +9497,50 @@ body.popup-mode .content-area {
.bookslm-action-apply:not(:disabled):hover { filter: brightness(1.08); }
/* 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). */
/* Agent tool calls collapse into a discreet “N steps” line (Notion-style). */
/* Tool-call trace shown above an assistant answer (agent mode).
The header is the live indicator (« ●●● 3 étapes ▶ »); expanding it reveals
the chronological steps, thinking notes as « Réflexion » sub-sections and
web sources as clickable links. */
.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-trace > summary { display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; color: var(--text-secondary); cursor: pointer; user-select: none;
list-style: none; padding: 2px 0; }
.bookslm-tool-trace > summary::-webkit-details-marker { display: none; }
.bookslm-tool-trace > summary:hover { color: var(--text-primary); }
.bookslm-tool-trace[open] > summary { margin-bottom: 4px; }
.bookslm-steps-dots { color: var(--accent); }
.bookslm-steps-body { display: flex; flex-direction: column; gap: 3px; }
.bookslm-chevron { font-size: 8px; line-height: 1; opacity: 0.7; }
.bookslm-chevron::before { content: '▶'; }
details[open] > summary .bookslm-chevron::before { content: '▼'; }
.bookslm-thought { margin-left: 12px; }
.bookslm-thought > summary { display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; color: var(--text-secondary); cursor: pointer; user-select: none;
list-style: none; }
.bookslm-thought > summary::-webkit-details-marker { display: none; }
.bookslm-thought > summary:hover { color: var(--text-primary); }
.bookslm-thought-text { margin: 4px 0 6px 6px; padding-left: 9px;
border-left: 2px solid var(--border); font-size: 11.5px; line-height: 1.5;
color: var(--text-secondary); font-style: italic; white-space: pre-wrap;
word-break: break-word; }
.bookslm-web-sources { margin-left: 12px; }
.bookslm-web-sources > summary { display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; color: var(--text-secondary); cursor: pointer; user-select: none;
list-style: none; }
.bookslm-web-sources > summary::-webkit-details-marker { display: none; }
.bookslm-web-sources > summary:hover { color: var(--text-primary); }
.bookslm-web-sources-list { list-style: none; margin: 4px 0 6px 6px; padding: 0;
display: flex; flex-direction: column; gap: 3px; border-left: 2px solid var(--border);
padding-left: 9px; }
.bookslm-web-sources-list a { font-size: 11px; color: var(--accent);
text-decoration: none; word-break: break-word; }
.bookslm-web-sources-list a::before { content: '↗ '; opacity: 0.7; }
.bookslm-web-sources-list a:hover { text-decoration: underline; }
.bookslm-tool-line { font-size: 11px; color: var(--text-secondary); padding-left: 12px; }
.bookslm-tool-line.failed { color: #f87171; }
@media (prefers-reduced-motion: reduce) {
.bookslm-dots i { animation: none; opacity: 0.6; }
}
/* Mutation confirmation card (two-step propose/apply). */
.bookslm-confirm { flex-wrap: wrap; }
.bookslm-confirm-diff { flex-basis: 100%; width: 100%; margin-top: 4px; }
+1 -1
View File
@@ -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 = 'v18';
const SW_VERSION = 'v19';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;