feat: assistant IA — approbation groupee des actions, bloc d'etapes, refresh UI et bouton Stop (BUG-074, BUG-075, BUG-076, BUG-077)
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 13m38s

This commit is contained in:
2026-09-24 10:05:32 -04:00
parent 9fb094f505
commit e1842043d8
23 changed files with 701 additions and 147 deletions
+195 -41
View File
@@ -52,6 +52,22 @@ const PANEL_MIN_WIDTH = 320;
const PANEL_MAX_WIDTH = 1000;
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
// BUG-076 — Tools that mutate the vault: their execution must refresh the
// sidebar tree immediately (the watcher SSE is delayed and directory-only
// changes may not produce an index event).
const MUTATING_TOOLS = new Set([
'create_file', 'create_directory', 'edit_file', 'append_to_file',
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
'delete_file', 'delete_directory', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
]);
// Subset carrying a concrete `vault` + `path`: the displayed document is
// reloaded from disk so an open viewer/editor reflects the agent's write.
const FILE_WRITE_TOOLS = new Set([
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
]);
/**
* BUG-059 — Is this pointer press a scrollbar drag (and only that)?
*
@@ -895,15 +911,14 @@ class BooksLM {
}
/**
* #93 — A write tool just modified a vault file. Notify the UI so the
* displayed document is reloaded from disk: otherwise the read view keeps the
* stale content, and an open editor buffer autosaves the old text back over
* the assistant's change (see utils.reloadExternalWrite).
* #93 / BUG-076 — A write tool just modified a vault file. Notify the UI so
* the displayed document is reloaded from disk: otherwise the read view keeps
* the stale content, and an open editor buffer autosaves the old text back
* over the assistant's change (see utils.reloadExternalWrite).
*/
_notifyFileWritten(data) {
if (!data || data.ok === false) return;
const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup'];
if (WRITE_TOOLS.indexOf(data.name) === -1) return;
if (!FILE_WRITE_TOOLS.has(data.name)) return;
const args = data.arguments || {};
if (!args.vault || !args.path) return;
window.dispatchEvent(
@@ -913,6 +928,24 @@ class BooksLM {
);
}
/**
* BUG-076 — Refresh the sidebar tree right after an agent mutation, without
* waiting for the (debounced) watcher SSE. Debounced so a batch of tool
* events (e.g. a folder plus its files) triggers a single refresh.
*/
_scheduleTreeRefresh() {
if (this._treeRefreshTimer) clearTimeout(this._treeRefreshTimer);
this._treeRefreshTimer = setTimeout(async () => {
this._treeRefreshTimer = null;
try {
const m = await import('./sidebar.js');
if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
await m.refreshSidebarTreePreservingState();
}
} catch { /* tree refresh is best-effort */ }
}, 250);
}
// ── Rendering ───────────────────────────────────────────────────────
_render() {
@@ -1041,7 +1074,11 @@ class BooksLM {
}
});
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => {
// BUG-077: the same button stops the run while a response streams.
if (this._isLoading) this._stopGeneration();
else this._sendMessage();
});
// Resizable panel (drag the left edge).
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
@@ -2665,6 +2702,17 @@ class BooksLM {
return t('ai.tool_call', { name: call.name });
}
/**
* BUG-074 — number of *action* steps of a message (reasoning notes are not
* actions). Falls back to the total when the block holds only thoughts so a
* “0 étape” label can never appear.
*/
_actionStepCount(toolCalls) {
const list = toolCalls || [];
const actions = list.filter((c) => !(c.step && c.step.key === 'thought'));
return actions.length || list.length;
}
/** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */
_chevron() {
const c = document.createElement('span');
@@ -2698,13 +2746,32 @@ class BooksLM {
dots.classList.add('bookslm-steps-dots');
summary.appendChild(dots);
}
const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
// BUG-074: the counter reflects *actions*, not reasoning notes, and the
// collapsed header also previews the first action so an “N étapes ▶” line
// is no longer an opaque title.
const actionCalls = toolCalls.filter((c) => !(c.step && c.step.key === 'thought'));
const count = this._actionStepCount(toolCalls);
const countKey = count > 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 });
label.textContent = t(countKey, { count });
summary.appendChild(label);
const first = actionCalls[0];
let preview = '';
if (first) {
preview = this._stepText(first);
const title = document.createElement('span');
title.className = 'bookslm-steps-title';
title.textContent = `— ${preview}`;
summary.appendChild(title);
}
summary.appendChild(this._chevron());
if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`);
if (running) {
summary.setAttribute(
'aria-label',
`${label.textContent}${preview ? ` — ${preview}` : ''} — ${t('ai.steps_running')}`,
);
}
wrap.appendChild(summary);
const body = document.createElement('div');
@@ -2804,8 +2871,11 @@ class BooksLM {
const conf = msg.confirmation;
const pending = conf.pending || {};
const error = pending.error || pending;
const tool = error.tool || 'action';
const args = error.arguments || {};
// BUG-075: the run batches every mutating call of the LLM turn into
// `pending.actions`; fall back to the single legacy `error` shape.
const actions = (Array.isArray(pending.actions) && pending.actions.length)
? pending.actions
: [{ id: error.id, tool: error.tool, arguments: error.arguments || {}, step: null }];
const card = document.createElement('div');
card.className = 'bookslm-action bookslm-confirm';
@@ -2814,23 +2884,48 @@ class BooksLM {
meta.className = 'bookslm-action-meta';
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
const textEl = document.createElement('span');
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
if (actions.length > 1) {
textEl.textContent = t('ai.confirm_actions', { count: actions.length });
} else {
const tool = actions[0].tool || 'action';
const args = actions[0].arguments || {};
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
}
meta.appendChild(textEl);
card.appendChild(meta);
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (conf._diffHtml) {
diffHost.innerHTML = conf._diffHtml;
} else if (!conf._diffLoading) {
conf._diffLoading = true;
this._fillConfirmationDiff(args, conf).then(() => this._renderMessages());
// One row per action with a diff preview when it writes file content.
const hasContent = (a) => {
const args = a.arguments || {};
return typeof args.content === 'string' && args.vault && args.path;
};
if (actions.length > 1) {
const list = document.createElement('div');
list.className = 'bookslm-confirm-actions';
for (const action of actions) {
const args = action.arguments || {};
const row = document.createElement('details');
row.className = 'bookslm-confirm-action';
const summary = document.createElement('summary');
const text = document.createElement('span');
text.textContent = this._stepText({ step: action.step, name: action.tool })
+ (args.path ? ` — ${args.path}` : '');
summary.appendChild(text);
if (hasContent(action)) summary.appendChild(this._chevron());
row.appendChild(summary);
if (hasContent(action)) row.appendChild(this._diffHost(action, args));
list.appendChild(row);
}
card.appendChild(list);
} else if (hasContent(actions[0])) {
card.appendChild(this._diffHost(conf, actions[0].arguments || {}));
}
card.appendChild(diffHost);
const apply = document.createElement('button');
apply.className = 'bookslm-action-apply';
apply.textContent = t('ai.action_apply');
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
apply.addEventListener('click', async () => {
apply.disabled = true;
apply.textContent = t('ai.action_applying');
@@ -2839,7 +2934,9 @@ class BooksLM {
apply.textContent = t('ai.action_applied');
} catch (e) {
apply.disabled = false;
apply.textContent = t('ai.action_apply');
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
showToast(t('ai.action_failed', { error: e.message }), 'error');
}
});
@@ -2847,6 +2944,19 @@ class BooksLM {
return card;
}
/** Diff host for one confirmation action (lazy LCS diff, cached on the host). */
_diffHost(host, args) {
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (host._diffHtml) {
diffHost.innerHTML = host._diffHtml;
} else if (!host._diffLoading) {
host._diffLoading = true;
this._fillConfirmationDiff(args, host).then(() => this._renderMessages());
}
return diffHost;
}
async _fillConfirmationDiff(args, conf) {
const proposed = args.content;
if (typeof proposed !== 'string' || !args.vault || !args.path) return;
@@ -2935,22 +3045,20 @@ class BooksLM {
if (!conf || conf._applying) return;
conf._applying = true;
// BUG-075: one click applies the whole pending batch AND authorizes the
// remaining actions of the same run (no second confirmation card).
const payload = {
...(msg.payload || {}),
confirm: conf.pending,
confirm_messages: conf.messages,
confirm_all: true,
};
this._isLoading = true;
this._abortCtrl = new AbortController();
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._syncSendButton();
this._setActivity('working', t('ai.activity_streaming'));
// BUG-046: carry the original request payload over to the continuation.
// When an applied tool failed and the model re-proposed a confirmation,
// the continuation used to have `payload: null`: the second "Appliquer"
// then resumed without `message` → 422 → "[object Object]" toast.
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: msg.payload ? { ...msg.payload } : null };
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
@@ -2960,21 +3068,65 @@ class BooksLM {
if (!resp.ok) {
throw await this._responseError(resp);
}
// The confirmation is resolved: drop the card and show the continuation.
// The confirmation is resolved: drop the card and keep streaming into the
// SAME message, so the steps block accumulates the whole exchange
// instead of fragmenting into a new “1 étape” message per approval
// (BUG-074). BUG-046 payload carry-over is inherent: `msg.payload` stays.
msg.confirmation = null;
this._messages.push(continuation);
this._renderMessages({ anchor: true });
await this._streamResponse(resp, continuation, payload);
await this._streamResponse(resp, msg, payload);
} catch (e) {
if (e.name === 'AbortError') {
this._markStopped(msg);
} else {
throw e;
}
} finally {
conf._applying = false;
this._isLoading = false;
this._abortCtrl = null;
if (sendBtn) sendBtn.disabled = false;
this._syncSendButton();
this._renderMessages();
this._saveHistory();
}
}
/** BUG-077 — abort the in-flight agent/chat request. */
_stopGeneration() {
if (this._abortCtrl) {
try {
this._abortCtrl.abort();
} catch { /* already aborted */ }
}
}
/** Append a visible « stopped by the user » marker to an assistant message. */
_markStopped(msg) {
const marker = `⏹ ${t('ai.stopped')}`;
const content = String(msg.content || '');
if (!content.includes(marker)) {
msg.content = content ? `${content}\n\n${marker}` : marker;
}
this._setActivity('idle');
}
/**
* BUG-077 — the composer's round button doubles as a Stop control while a
* response streams: a new send is already blocked by `_isLoading`, so the
* button switches to a stop icon instead of being disabled.
*/
_syncSendButton() {
const btn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (!btn) return;
const loading = !!this._isLoading;
btn.classList.toggle('is-stopping', loading);
btn.title = loading ? t('ai.stop') : t('bookslm.send');
btn.setAttribute('aria-label', btn.title);
const icon = btn.querySelector('i');
if (icon) icon.setAttribute('data-lucide', loading ? 'square' : 'arrow-up');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
// ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() {
@@ -3053,10 +3205,8 @@ class BooksLM {
this._isLoading = true;
this._renderMessages({ anchor: true, instant: true });
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
this._syncSendButton();
this._setActivity('working', t('ai.activity_sending'));
try {
@@ -3085,17 +3235,17 @@ class BooksLM {
}
this._setActivity('done', t('ai.activity_done'));
} catch (e) {
if (e.name !== 'AbortError') {
if (e.name === 'AbortError') {
this._markStopped(assistantMsg);
} else {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('AI assistant chat error:', e);
this._setActivity('error', t('ai.activity_error'));
} else {
this._setActivity('idle');
}
}
this._isLoading = false;
if (sendBtn) sendBtn.disabled = false;
this._syncSendButton();
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
@@ -3155,6 +3305,10 @@ class BooksLM {
});
// #93 — a vault write must be reflected in the displayed document.
this._notifyFileWritten(data);
// BUG-076 — reflect tree changes (create/delete/rename…) right away.
if (data.ok !== false && MUTATING_TOOLS.has(data.name)) {
this._scheduleTreeRefresh();
}
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
return;
}