diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go
index bc72aec..10c30e2 100644
--- a/internal/loki/chat_conversation.go
+++ b/internal/loki/chat_conversation.go
@@ -289,6 +289,15 @@ func (c *Conversation) state() map[string]any {
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
}
+// isGenerating dit si un tour est en cours. Sert à la LISTE des discussions :
+// elle doit pouvoir montrer laquelle travaille dès le chargement de la page,
+// sans attendre que le rejeu du flux SSE ait rattrapé son retard.
+func (c *Conversation) isGenerating() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.Generating
+}
+
// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
var ErrBusy = fmt.Errorf("génération en cours")
diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index daff7c6..154beb5 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -475,7 +475,12 @@ button:disabled{opacity:.5;cursor:not-allowed}
mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux
éléments pour un seul état d'attente. Ici les points passent en accent et un
libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */
-.msg.typing.compacting span:not(.tlabel){background:var(--accent)}
+.msg.typing.compacting span:not(.tlabel):not(.wclock){background:var(--accent)}
+/* Compteur de travail sur l'indicateur « … » : il annule la mise en forme de
+ pastille que `.msg.typing span` applique à TOUT span de la bulle. */
+.msg.typing .wclock{width:auto;height:auto;border-radius:0;background:none;animation:none;
+ font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--dim);
+ margin-left:7px;white-space:nowrap}
.msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap}
@keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}}
/* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur
@@ -1110,7 +1115,12 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em;
color:var(--dim);opacity:.75}
#chat .msg .statline:empty{display:none}
-html[data-hide-stats="1"] #chat .msg .statline{display:none}
+/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de
+ travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la
+ réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en
+ entier — sans .has-work il ne resterait qu'un blanc. */
+html[data-hide-stats="1"] #chat .msg .statline>.spd{display:none}
+html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
/* Arrivée en fondu montant plutôt qu'un surgissement sec. L'opacité finale (.75)
est celle de la règle ci-dessus : l'animation doit y ATTERRIR, sinon la ligne
clignote en repassant à sa valeur de repos. */
@@ -1645,6 +1655,25 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
+/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
+ élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
+ agent est en train de travailler, y compris depuis un autre appareil. */
+/* ⚠️ Sélecteur en DEUX classes : `.preset>span` (plus haut) pose flex:1 sur tout
+ span enfant d'une ligne — l'anneau prenait sinon la moitié de la largeur et
+ écrasait le titre, exactement comme l'heure avant lui. */
+.preset>.conv-spin,#topbar>.conv-spin{flex:none;display:inline-flex;align-items:center;
+ justify-content:center;line-height:0;color:var(--accent)}
+.conv-spin svg{animation:conv-spin 900ms linear infinite}
+@keyframes conv-spin{to{transform:rotate(360deg)}}
+/* Mouvement réduit : l'anneau ne tourne plus mais respire — l'information reste,
+ le mouvement de rotation disparaît. */
+@media (prefers-reduced-motion:reduce){
+ .conv-spin svg{animation:conv-breathe 1.6s ease-in-out infinite}
+ @keyframes conv-breathe{0%,100%{opacity:1}50%{opacity:.25}}
+}
+/* Dans l'en-tête, l'anneau précède le titre : même gabarit que les boutons
+ voisins pour ne pas décaler la ligne quand il apparaît et disparaît. */
+#topbar>.conv-spin{width:18px}
/* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette
seule ligne, pour que la barre latérale de la maquette (discussions +
machine) reste la vue par défaut. */
@@ -3016,6 +3045,9 @@ const ICONS = {
plus: '',
chevron: '',
search: '',
+ // Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité,
+ // le dessin reste au même trait que le reste du jeu.
+ spinner: '',
sun: '',
};
@@ -5199,13 +5231,21 @@ function setLabelCounts(el, add, del){
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
lab.appendChild(cnt);
}
-// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki
-// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
-// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
-// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
-function setStats(el, text){
+// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki sont masquées dans
+// cette mise en page, donc les chiffres qu'on y écrivait avaient disparu : ils
+// ont leur propre ligne, discrète, sous le texte.
+//
+// Deux informations de nature différente y cohabitent, d'où deux éléments :
+// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la
+// réponse, outils et raisonnement compris) ;
+// .spd — les mesures de vitesse (prefill / decode).
+// Seule la seconde est escamotée par « masquer la vitesse de génération » : la
+// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en
+// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse.
+function setStats(el, work, speed){
if(!el) return;
let s = el.querySelector(':scope > .statline');
+ if(!work && !speed){ if(s) s.textContent=''; return; }
if(!s){
s=document.createElement('div'); s.className='statline';
// Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois
@@ -5215,7 +5255,18 @@ function setStats(el, text){
if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in');
el.appendChild(s);
}
- s.textContent = text;
+ // Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne
+ // est repeinte à chaque token reçu, y recréer des éléments ferait travailler la
+ // mise en page des dizaines de fois par seconde pour rien.
+ let w = s.querySelector(':scope > .worktime');
+ let p = s.querySelector(':scope > .spd');
+ if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); }
+ if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); }
+ w.textContent = work || '';
+ // Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser
+ // un « · » orphelin en bout de durée.
+ p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : '';
+ s.classList.toggle('has-work', !!work);
}
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
@@ -5543,8 +5594,67 @@ function confirmPending(text){
let REPLAYING=true;
// État de rendu du tour courant, délimité par les événements user / turn_done.
let T=null;
-function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; }
+function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'' }; }
newTurn();
+
+// --- Temps de travail du tour ------------------------------------------------
+// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la
+// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes
+// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user`
+// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal,
+// où tout arrive d'un bloc côté client.
+//
+// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la
+// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un
+// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART
+// entre les deux, réévalué à chaque événement reçu en direct.
+let TS_SKEW = 0; // horloge client − horloge serveur (ms)
+function nowServer(){ return Date.now() - TS_SKEW; }
+let WORK_TIMER = null;
+function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); }
+function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } }
+function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); }
+// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
+// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
+// c'est précisément le moment où l'on se demande si ça avance.
+function paintTypingClock(){
+ const el = T.typingEl;
+ if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé
+ if(!T.startTs) return;
+ const ms = nowServer() - T.startTs;
+ if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate
+ let c = el.querySelector('.wclock');
+ if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); }
+ c.textContent = fmtDur(ms);
+}
+// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction),
+// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste
+// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule
+// y ferait deux conventions à trois mots d'écart.
+function fmtDur(ms){
+ if(!(ms > 0)) return '';
+ if(ms < 10000) return (ms/1000).toFixed(1) + ' s';
+ const t = Math.round(ms/1000);
+ if(t < 60) return t + ' s';
+ const m = Math.floor(t/60), sec = t % 60;
+ if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s';
+ return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min';
+}
+// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour
+// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens).
+function workLabel(){
+ if(!T.startTs) return '';
+ const ms = (T.doneTs || nowServer()) - T.startTs;
+ if(!(ms > 300)) return '';
+ return 'travail ' + fmtDur(ms);
+}
+// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite.
+// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le
+// cas du tic de seconde, qui ne fait avancer que la durée).
+function paintStats(el, speed){
+ if(speed !== undefined) T.speedText = speed;
+ setStats(el, workLabel(), T.speedText);
+}
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
@@ -5603,8 +5713,8 @@ function renderStats(el, s){
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
// bouton de repli, on y écrit comme avant.
- if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join(' · '));
- else setStats(el, parts.join(' · '));
+ if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
+ else paintStats(el, parts.join(' · '));
}
// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
@@ -5614,18 +5724,25 @@ function labelTokens(el, role, n, firstTs, lastTs){
if(!el) return;
const secs=(lastTs-firstTs)/1000;
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
+ // Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi
+ // pendant… » d'un fil de discussion — elle reste lisible une fois repliée.
+ const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : '';
// Bulle technique : son étiquette EST le bouton de repli, les mesures y vont.
// Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures
// vont dans la ligne dédiée — comme le font déjà les stats de fin de tour
// (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct
// par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement.
- if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m);
- else setStats(el, m);
+ if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · '));
+ else paintStats(el, m);
}
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
-function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
+function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn();
+ // La liste des discussions montre laquelle travaille : elle doit suivre l'état
+ // MÊME pendant le rejeu (une page rechargée en cours de génération doit voir
+ // l'anneau tourner sans attendre le premier événement en direct).
+ if(typeof convSyncBusy==='function') convSyncBusy(); }
function syncSendBtn(){
const sb=document.getElementById('send');
// ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux
@@ -5652,11 +5769,19 @@ function syncSendBtn(){
// piloté par le serveur et rejouable à l'identique.
function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
+ // Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
+ // `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
+ // pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
+ if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
if(d.caught_up){
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
+ // Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
+ // des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
+ // faut le lancer maintenant, sinon la durée resterait figée à l'écran.
+ if(busy && T.startTs && !T.doneTs) startWorkTimer();
// Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
syncChatEmpty();
@@ -5665,7 +5790,7 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
- if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
+ if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
@@ -5674,11 +5799,20 @@ function handleDelta(d){
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
// bulle neuve — sinon elles apparaîtraient en double.
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
- setBusy(true); T.typingEl=addTyping(); return; }
+ // Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure
+ // locale — c'est ce qui rend la durée juste au rejeu comme en direct.
+ T.startTs = d.ts || 0; T.doneTs = 0;
+ setBusy(true); startWorkTimer(); T.typingEl=addTyping(); return; }
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
// replay, qui rejoue tous les tours passés d'un bloc.
- if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
+ if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
+ // La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
+ // le temps réellement passé et non continuer d'avancer.
+ T.doneTs = d.ts || T.doneTs || 0;
+ if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
+ else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
+ setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
@@ -7155,12 +7289,18 @@ function fmtWhen(sec){
// titre, dates, nombre d'échanges — jamais les messages.
let CONVS = [], CONV_ACTIVE = '';
+// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
+// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la
+// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
+// route, ou ouverte depuis un autre appareil).
+let CONV_BUSY = false;
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
CONV_ACTIVE = r.active || '';
+ CONV_BUSY = !!r.busy;
renderConversations();
}
@@ -7178,11 +7318,16 @@ function renderConversations(){
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
cont.appendChild(e);
syncTopbarTitle();
+ convSyncBusy();
return;
}
for(const c of list){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
+ // L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
+ // qui travaille sans redessiner la liste (un redessin relancerait
+ // l'animation de l'anneau à chaque événement du flux).
+ row.dataset.conv = c.id;
row.onclick = () => convSwitch(c.id);
const info = document.createElement('div'); info.className = 'preset-info';
@@ -7215,6 +7360,57 @@ function renderConversations(){
cont.appendChild(row);
}
syncTopbarTitle();
+ convSyncBusy();
+}
+
+// --- Discussion au travail : anneau qui tourne ------------------------------
+// Une seule génération tourne à la fois, et c'est forcément sur la discussion
+// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
+// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané)
+// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le
+// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui
+// n'est rechargée qu'en fin de tour.
+function convBusyNow(){
+ const live = (typeof busy !== 'undefined') && busy;
+ if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live;
+ return CONV_BUSY || !!live;
+}
+
+// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son
+// contenu : réécrire l'hôte remettrait l'animation à zéro.
+function convSpinToggle(host, on, before){
+ if(!host) return;
+ let sp = host.querySelector(':scope > .conv-spin');
+ if(on && !sp){
+ sp = document.createElement('span');
+ sp.className = 'conv-spin';
+ sp.title = 'l\u2019agent travaille sur cette discussion\u2026';
+ sp.setAttribute('role', 'img');
+ sp.setAttribute('aria-label', 'génération en cours');
+ sp.appendChild(icon('spinner', 14));
+ if(before) host.insertBefore(sp, before); else host.appendChild(sp);
+ } else if(!on && sp){
+ sp.remove();
+ }
+}
+
+// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion
+// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans
+// ça, rien ne dirait que ça travaille).
+function convSyncBusy(){
+ const on = convBusyNow();
+ const cont = document.getElementById('conv-list');
+ if(cont){
+ for(const row of cont.querySelectorAll('.preset')){
+ const mine = on && row.dataset.conv === CONV_ACTIVE;
+ row.classList.toggle('working', mine);
+ convSpinToggle(row, mine, row.firstChild);
+ }
+ }
+ // Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte
+ // à l'ellipse, un enfant y serait rogné avec lui sur un titre long.
+ const tb = document.getElementById('topbar-title');
+ convSpinToggle(tb && tb.parentNode, on, tb);
}
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
diff --git a/internal/loki/ui/src/js/03-icons.js b/internal/loki/ui/src/js/03-icons.js
index 75e4c0e..54c7dd9 100644
--- a/internal/loki/ui/src/js/03-icons.js
+++ b/internal/loki/ui/src/js/03-icons.js
@@ -26,6 +26,9 @@ const ICONS = {
plus: '',
chevron: '',
search: '',
+ // Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité,
+ // le dessin reste au même trait que le reste du jeu.
+ spinner: '',
sun: '',
};
diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js
index 981db7b..718e813 100644
--- a/internal/loki/ui/src/js/08-chat-render.js
+++ b/internal/loki/ui/src/js/08-chat-render.js
@@ -153,13 +153,21 @@ function setLabelCounts(el, add, del){
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
lab.appendChild(cnt);
}
-// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki
-// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
-// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
-// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
-function setStats(el, text){
+// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki sont masquées dans
+// cette mise en page, donc les chiffres qu'on y écrivait avaient disparu : ils
+// ont leur propre ligne, discrète, sous le texte.
+//
+// Deux informations de nature différente y cohabitent, d'où deux éléments :
+// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la
+// réponse, outils et raisonnement compris) ;
+// .spd — les mesures de vitesse (prefill / decode).
+// Seule la seconde est escamotée par « masquer la vitesse de génération » : la
+// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en
+// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse.
+function setStats(el, work, speed){
if(!el) return;
let s = el.querySelector(':scope > .statline');
+ if(!work && !speed){ if(s) s.textContent=''; return; }
if(!s){
s=document.createElement('div'); s.className='statline';
// Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois
@@ -169,7 +177,18 @@ function setStats(el, text){
if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in');
el.appendChild(s);
}
- s.textContent = text;
+ // Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne
+ // est repeinte à chaque token reçu, y recréer des éléments ferait travailler la
+ // mise en page des dizaines de fois par seconde pour rien.
+ let w = s.querySelector(':scope > .worktime');
+ let p = s.querySelector(':scope > .spd');
+ if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); }
+ if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); }
+ w.textContent = work || '';
+ // Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser
+ // un « · » orphelin en bout de durée.
+ p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : '';
+ s.classList.toggle('has-work', !!work);
}
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js
index 29ea230..d6cc63a 100644
--- a/internal/loki/ui/src/js/09-stream.js
+++ b/internal/loki/ui/src/js/09-stream.js
@@ -51,8 +51,67 @@ function confirmPending(text){
let REPLAYING=true;
// État de rendu du tour courant, délimité par les événements user / turn_done.
let T=null;
-function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; }
+function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'' }; }
newTurn();
+
+// --- Temps de travail du tour ------------------------------------------------
+// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la
+// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes
+// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user`
+// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal,
+// où tout arrive d'un bloc côté client.
+//
+// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la
+// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un
+// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART
+// entre les deux, réévalué à chaque événement reçu en direct.
+let TS_SKEW = 0; // horloge client − horloge serveur (ms)
+function nowServer(){ return Date.now() - TS_SKEW; }
+let WORK_TIMER = null;
+function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); }
+function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } }
+function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); }
+// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
+// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
+// c'est précisément le moment où l'on se demande si ça avance.
+function paintTypingClock(){
+ const el = T.typingEl;
+ if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé
+ if(!T.startTs) return;
+ const ms = nowServer() - T.startTs;
+ if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate
+ let c = el.querySelector('.wclock');
+ if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); }
+ c.textContent = fmtDur(ms);
+}
+// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction),
+// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste
+// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule
+// y ferait deux conventions à trois mots d'écart.
+function fmtDur(ms){
+ if(!(ms > 0)) return '';
+ if(ms < 10000) return (ms/1000).toFixed(1) + ' s';
+ const t = Math.round(ms/1000);
+ if(t < 60) return t + ' s';
+ const m = Math.floor(t/60), sec = t % 60;
+ if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s';
+ return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min';
+}
+// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour
+// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens).
+function workLabel(){
+ if(!T.startTs) return '';
+ const ms = (T.doneTs || nowServer()) - T.startTs;
+ if(!(ms > 300)) return '';
+ return 'travail ' + fmtDur(ms);
+}
+// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite.
+// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le
+// cas du tic de seconde, qui ne fait avancer que la durée).
+function paintStats(el, speed){
+ if(speed !== undefined) T.speedText = speed;
+ setStats(el, workLabel(), T.speedText);
+}
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
@@ -111,8 +170,8 @@ function renderStats(el, s){
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
// bouton de repli, on y écrit comme avant.
- if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join(' · '));
- else setStats(el, parts.join(' · '));
+ if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
+ else paintStats(el, parts.join(' · '));
}
// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
@@ -122,18 +181,25 @@ function labelTokens(el, role, n, firstTs, lastTs){
if(!el) return;
const secs=(lastTs-firstTs)/1000;
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
+ // Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi
+ // pendant… » d'un fil de discussion — elle reste lisible une fois repliée.
+ const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : '';
// Bulle technique : son étiquette EST le bouton de repli, les mesures y vont.
// Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures
// vont dans la ligne dédiée — comme le font déjà les stats de fin de tour
// (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct
// par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement.
- if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m);
- else setStats(el, m);
+ if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · '));
+ else paintStats(el, m);
}
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
-function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
+function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn();
+ // La liste des discussions montre laquelle travaille : elle doit suivre l'état
+ // MÊME pendant le rejeu (une page rechargée en cours de génération doit voir
+ // l'anneau tourner sans attendre le premier événement en direct).
+ if(typeof convSyncBusy==='function') convSyncBusy(); }
function syncSendBtn(){
const sb=document.getElementById('send');
// ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux
@@ -160,11 +226,19 @@ function syncSendBtn(){
// piloté par le serveur et rejouable à l'identique.
function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
+ // Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
+ // `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
+ // pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
+ if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
if(d.caught_up){
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
+ // Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
+ // des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
+ // faut le lancer maintenant, sinon la durée resterait figée à l'écran.
+ if(busy && T.startTs && !T.doneTs) startWorkTimer();
// Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
syncChatEmpty();
@@ -173,7 +247,7 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
- if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
+ if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
@@ -182,11 +256,20 @@ function handleDelta(d){
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
// bulle neuve — sinon elles apparaîtraient en double.
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
- setBusy(true); T.typingEl=addTyping(); return; }
+ // Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure
+ // locale — c'est ce qui rend la durée juste au rejeu comme en direct.
+ T.startTs = d.ts || 0; T.doneTs = 0;
+ setBusy(true); startWorkTimer(); T.typingEl=addTyping(); return; }
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
// replay, qui rejoue tous les tours passés d'un bloc.
- if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
+ if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
+ // La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
+ // le temps réellement passé et non continuer d'avancer.
+ T.doneTs = d.ts || T.doneTs || 0;
+ if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
+ else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
+ setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
diff --git a/internal/loki/ui/src/js/16-conversations.js b/internal/loki/ui/src/js/16-conversations.js
index 28139e3..845fdf5 100644
--- a/internal/loki/ui/src/js/16-conversations.js
+++ b/internal/loki/ui/src/js/16-conversations.js
@@ -14,12 +14,18 @@
// titre, dates, nombre d'échanges — jamais les messages.
let CONVS = [], CONV_ACTIVE = '';
+// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
+// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la
+// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
+// route, ou ouverte depuis un autre appareil).
+let CONV_BUSY = false;
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
CONV_ACTIVE = r.active || '';
+ CONV_BUSY = !!r.busy;
renderConversations();
}
@@ -37,11 +43,16 @@ function renderConversations(){
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
cont.appendChild(e);
syncTopbarTitle();
+ convSyncBusy();
return;
}
for(const c of list){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
+ // L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
+ // qui travaille sans redessiner la liste (un redessin relancerait
+ // l'animation de l'anneau à chaque événement du flux).
+ row.dataset.conv = c.id;
row.onclick = () => convSwitch(c.id);
const info = document.createElement('div'); info.className = 'preset-info';
@@ -74,6 +85,57 @@ function renderConversations(){
cont.appendChild(row);
}
syncTopbarTitle();
+ convSyncBusy();
+}
+
+// --- Discussion au travail : anneau qui tourne ------------------------------
+// Une seule génération tourne à la fois, et c'est forcément sur la discussion
+// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
+// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané)
+// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le
+// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui
+// n'est rechargée qu'en fin de tour.
+function convBusyNow(){
+ const live = (typeof busy !== 'undefined') && busy;
+ if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live;
+ return CONV_BUSY || !!live;
+}
+
+// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son
+// contenu : réécrire l'hôte remettrait l'animation à zéro.
+function convSpinToggle(host, on, before){
+ if(!host) return;
+ let sp = host.querySelector(':scope > .conv-spin');
+ if(on && !sp){
+ sp = document.createElement('span');
+ sp.className = 'conv-spin';
+ sp.title = 'l\u2019agent travaille sur cette discussion\u2026';
+ sp.setAttribute('role', 'img');
+ sp.setAttribute('aria-label', 'génération en cours');
+ sp.appendChild(icon('spinner', 14));
+ if(before) host.insertBefore(sp, before); else host.appendChild(sp);
+ } else if(!on && sp){
+ sp.remove();
+ }
+}
+
+// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion
+// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans
+// ça, rien ne dirait que ça travaille).
+function convSyncBusy(){
+ const on = convBusyNow();
+ const cont = document.getElementById('conv-list');
+ if(cont){
+ for(const row of cont.querySelectorAll('.preset')){
+ const mine = on && row.dataset.conv === CONV_ACTIVE;
+ row.classList.toggle('working', mine);
+ convSpinToggle(row, mine, row.firstChild);
+ }
+ }
+ // Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte
+ // à l'ellipse, un enfant y serait rogné avec lui sur un titre long.
+ const tb = document.getElementById('topbar-title');
+ convSpinToggle(tb && tb.parentNode, on, tb);
}
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index 0c87e05..d3f69dc 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -444,7 +444,12 @@ button:disabled{opacity:.5;cursor:not-allowed}
mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux
éléments pour un seul état d'attente. Ici les points passent en accent et un
libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */
-.msg.typing.compacting span:not(.tlabel){background:var(--accent)}
+.msg.typing.compacting span:not(.tlabel):not(.wclock){background:var(--accent)}
+/* Compteur de travail sur l'indicateur « … » : il annule la mise en forme de
+ pastille que `.msg.typing span` applique à TOUT span de la bulle. */
+.msg.typing .wclock{width:auto;height:auto;border-radius:0;background:none;animation:none;
+ font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--dim);
+ margin-left:7px;white-space:nowrap}
.msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap}
@keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}}
/* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur
@@ -1079,7 +1084,12 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em;
color:var(--dim);opacity:.75}
#chat .msg .statline:empty{display:none}
-html[data-hide-stats="1"] #chat .msg .statline{display:none}
+/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de
+ travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la
+ réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en
+ entier — sans .has-work il ne resterait qu'un blanc. */
+html[data-hide-stats="1"] #chat .msg .statline>.spd{display:none}
+html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
/* Arrivée en fondu montant plutôt qu'un surgissement sec. L'opacité finale (.75)
est celle de la règle ci-dessus : l'animation doit y ATTERRIR, sinon la ligne
clignote en repassant à sa valeur de repos. */
@@ -1614,6 +1624,25 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
+/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
+ élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
+ agent est en train de travailler, y compris depuis un autre appareil. */
+/* ⚠️ Sélecteur en DEUX classes : `.preset>span` (plus haut) pose flex:1 sur tout
+ span enfant d'une ligne — l'anneau prenait sinon la moitié de la largeur et
+ écrasait le titre, exactement comme l'heure avant lui. */
+.preset>.conv-spin,#topbar>.conv-spin{flex:none;display:inline-flex;align-items:center;
+ justify-content:center;line-height:0;color:var(--accent)}
+.conv-spin svg{animation:conv-spin 900ms linear infinite}
+@keyframes conv-spin{to{transform:rotate(360deg)}}
+/* Mouvement réduit : l'anneau ne tourne plus mais respire — l'information reste,
+ le mouvement de rotation disparaît. */
+@media (prefers-reduced-motion:reduce){
+ .conv-spin svg{animation:conv-breathe 1.6s ease-in-out infinite}
+ @keyframes conv-breathe{0%,100%{opacity:1}50%{opacity:.25}}
+}
+/* Dans l'en-tête, l'anneau précède le titre : même gabarit que les boutons
+ voisins pour ne pas décaler la ligne quand il apparaît et disparaît. */
+#topbar>.conv-spin{width:18px}
/* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette
seule ligne, pour que la barre latérale de la maquette (discussions +
machine) reste la vue par défaut. */
diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go
index 41c25ee..a7f12a7 100644
--- a/internal/loki/web_chat.go
+++ b/internal/loki/web_chat.go
@@ -130,7 +130,11 @@ func handleConvList(w http.ResponseWriter, r *http.Request) {
if list == nil {
list = []convMeta{}
}
- sendJSON(w, 200, map[string]any{"conversations": list, "active": active})
+ // `busy` = un tour est en cours SUR LA DISCUSSION ACTIVE (il n'y a qu'une
+ // génération à la fois, sur le fil ouvert). L'UI s'en sert pour animer la
+ // ligne concernée dès le chargement, avant même que le flux SSE ait rejoué
+ // le journal — sinon la page reste muette pendant qu'un agent travaille.
+ sendJSON(w, 200, map[string]any{"conversations": list, "active": active, "busy": conv.isGenerating()})
}
func handleConvNew(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/loki/web_conv_busy_test.go b/internal/loki/web_conv_busy_test.go
new file mode 100644
index 0000000..cee9ec7
--- /dev/null
+++ b/internal/loki/web_conv_busy_test.go
@@ -0,0 +1,48 @@
+package loki
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "testing"
+)
+
+// La liste des discussions doit dire LAQUELLE travaille : c'est ce qui fait
+// tourner l'anneau dans la barre latérale dès le chargement de la page, sans
+// attendre que le flux SSE ait rejoué son journal.
+func TestConvListRapporteLaGeneration(t *testing.T) {
+ withWorkspace(t)
+
+ busy := func() bool {
+ rr := httptest.NewRecorder()
+ handleConvList(rr, httptest.NewRequest("GET", "/api/conversations", nil))
+ if rr.Code != 200 {
+ t.Fatalf("code %d : %s", rr.Code, rr.Body.String())
+ }
+ var out struct {
+ Active string `json:"active"`
+ Busy bool `json:"busy"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ if out.Active == "" {
+ t.Fatal("aucune discussion active dans la liste")
+ }
+ return out.Busy
+ }
+
+ if busy() {
+ t.Fatal("busy=true alors qu'aucune génération ne tourne")
+ }
+ conv.mu.Lock()
+ conv.Generating = true
+ conv.mu.Unlock()
+ defer func() {
+ conv.mu.Lock()
+ conv.Generating = false
+ conv.mu.Unlock()
+ }()
+ if !busy() {
+ t.Fatal("busy=false alors qu'une génération tourne")
+ }
+}