diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index 57ea365..f3bc43d 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -1663,12 +1663,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
é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}
+/* L'animation porte sur le CONTENEUR (élément HTML), pas sur le : sur un
+ élément SVG, l'origine de la rotation dépend de transform-box, dont le défaut
+ a changé au fil des versions — l'anneau restait figé chez certains. Un span
+ inline-flex tourne partout, sans ambiguïté. Les deux propriétés restent
+ posées sur le svg pour le cas où une règle le viserait encore. */
+.conv-spin{animation:conv-spin 900ms linear infinite;transform-origin:center}
+.conv-spin svg{transform-box:fill-box;transform-origin:center}
@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}
+ .conv-spin{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
@@ -2002,17 +2008,30 @@ html[data-files="1"] #files-btn{color:var(--accent)}
#mode-seg button.on{background:var(--accent-bg);color:var(--accent)}
#mode-seg button:not(.on):hover{color:var(--text)}
-#code-hint{display:flex;align-items:center;gap:8px;margin:0 auto 6px;max-width:820px;padding:6px 10px;border:1px solid var(--border);border-left:2px solid var(--accent);border-radius:3px;background:var(--panel);color:var(--dim);font-size:12px}
+/* Bandeaux du composeur (chrono du tour, puce mode code, critères). Ils vivent
+ DANS #composer — qui est en position:absolute — et suivent donc sa colonne
+ (--col). Posés avant lui dans le DOM, ils restaient dans le flux de la page
+ et disparaissaient SOUS la carte de saisie. */
+.composer-banner{margin:0 var(--col) 6px;padding:6px 12px;border:1px solid var(--border);border-radius:4px;background:var(--panel);color:var(--dim);font-size:12px;font-family:var(--mono)}
+#turn-clock{display:flex;align-items:center;gap:6px;letter-spacing:.03em}
+#turn-clock::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--accent);animation:tcpulse 1.4s ease-in-out infinite}
+#turn-clock.done{color:var(--accent)}
+#turn-clock.done::before{animation:none;opacity:.6}
+@keyframes tcpulse{0%,100%{opacity:.25}50%{opacity:1}}
+
+#code-hint{display:flex;align-items:center;gap:8px;font-size:12px}
#code-hint button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit}
#code-hint button:hover{background:var(--accent-bg)}
#code-hint #code-hint-no{border-color:var(--border);color:var(--dim);margin-left:auto}
-#criteria-box{margin:0 auto 6px;max-width:820px;border:1px solid var(--border);border-radius:3px;background:var(--panel);font-size:12px;overflow:hidden}
-#criteria-box.allpass{border-left:2px solid var(--accent)}
-#criteria-head{padding:5px 10px;color:var(--dim);cursor:pointer;user-select:none;letter-spacing:.03em}
+#criteria-box{padding:0;overflow:hidden;max-height:38vh;overflow-y:auto}
+#criteria-box.allpass{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))}
+#criteria-head{padding:6px 12px;color:var(--dim);cursor:pointer;user-select:none;letter-spacing:.03em;position:sticky;top:0;background:var(--panel)}
+#criteria-head::after{content:' ▾';opacity:.5}
+#criteria-box.folded #criteria-head::after{content:' ▸'}
#criteria-box.allpass #criteria-head{color:var(--accent)}
#criteria-box.folded #criteria-list{display:none}
-#criteria-list{list-style:none;margin:0;padding:0 10px 6px}
+#criteria-list{list-style:none;margin:0;padding:0 12px 8px}
#criteria-list li{padding:2px 0;color:var(--text)}
#criteria-list li.crit-passed{color:var(--accent)}
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
@@ -5707,7 +5726,28 @@ 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(); }
+function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); paintTurnClock(); }
+
+// Chrono du TOUR COMPLET, affiché au-dessus de la carte de saisie : le temps
+// entre l'envoi du message et le moment où l'on peut reparler — outils, passes
+// de vérification et compaction compris. Les durées portées par les cartes ne
+// disent que la génération de texte ; celle-ci répond à « ça a pris combien de
+// temps, en tout ? ».
+function paintTurnClock(){
+ const composer = document.getElementById('composer');
+ if(!composer || !T.startTs) return;
+ let el = document.getElementById('turn-clock');
+ if(!el){
+ el = document.createElement('div');
+ el.id = 'turn-clock';
+ el.className = 'composer-banner';
+ composer.insertBefore(el, composer.firstChild);
+ }
+ const ms = (T.doneTs || nowServer()) - T.startTs;
+ if(!(ms > 0)){ el.remove(); return; }
+ el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms);
+ el.classList.toggle('done', !!T.doneTs);
+}
// 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.
@@ -5898,7 +5938,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){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
+ if(d.reset!==undefined){ stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -5917,7 +5957,7 @@ function handleDelta(d){
// 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; }
+ setBusy(true); startWorkTimer(); paintTurnClock(); 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.
@@ -5927,6 +5967,7 @@ function handleDelta(d){
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
+ paintTurnClock(); // fige le total au-dessus de la carte
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; }
@@ -7949,14 +7990,28 @@ async function modeOnConvChange(){
// Émise par le serveur (delta code_hint) quand un message de mode chat
// ressemble à une tâche de code. Ignorable, jamais bloquante.
+// Insère un bandeau DANS le composeur, au-dessus de la barre de saisie.
+// #composer est en position:absolute : un élément posé AVANT lui dans le DOM
+// reste dans le flux normal de la page et se retrouve donc CACHÉ dessous. Ses
+// enfants, eux, suivent sa colonne (--col) et son empilement.
+function composerBanner(id){
+ const composer = document.getElementById('composer');
+ if(!composer) return null;
+ let el = document.getElementById(id);
+ if(!el){
+ el = document.createElement('div');
+ el.id = id;
+ el.className = 'composer-banner';
+ composer.insertBefore(el, composer.firstChild);
+ }
+ return el;
+}
+
function showCodeHint(){
if(CURRENT_MODE==='code' || document.getElementById('code-hint')) return;
- const composer = document.getElementById('composer');
- if(!composer) return;
- const chip = document.createElement('div');
- chip.id = 'code-hint';
+ const chip = composerBanner('code-hint');
+ if(!chip) return;
chip.innerHTML = 'ça ressemble à une tâche de code — passer en mode Code ✕ ';
- composer.parentNode.insertBefore(chip, composer);
chip.querySelector('#code-hint-yes').onclick = ()=>{ setMode('code'); };
chip.querySelector('#code-hint-no').onclick = hideCodeHint;
}
@@ -7974,14 +8029,15 @@ function renderCriteria(list){
let box = document.getElementById('criteria-box');
if(!list || !list.length){ if(box) box.remove(); return; }
if(CURRENT_MODE!=='code'){ if(box) box.remove(); return; }
- if(!box){
- const composer = document.getElementById('composer');
- if(!composer) return;
- box = document.createElement('div');
- box.id = 'criteria-box';
+ const fresh = !box;
+ box = composerBanner('criteria-box');
+ if(!box) return;
+ if(fresh){
box.innerHTML = '
';
- composer.parentNode.insertBefore(box, composer);
box.querySelector('#criteria-head').onclick = ()=> box.classList.toggle('folded');
+ // Repliée par défaut : cinq critères dépliés mangeaient la moitié de
+ // l'écran au-dessus de la saisie. L'en-tête porte déjà le compteur.
+ box.classList.add('folded');
}
box.style.display='';
const passed = list.filter(c=>c.status==='passed').length;
diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js
index 09a61b9..44e0c1e 100644
--- a/internal/loki/ui/src/js/09-stream.js
+++ b/internal/loki/ui/src/js/09-stream.js
@@ -70,7 +70,28 @@ 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(); }
+function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); paintTurnClock(); }
+
+// Chrono du TOUR COMPLET, affiché au-dessus de la carte de saisie : le temps
+// entre l'envoi du message et le moment où l'on peut reparler — outils, passes
+// de vérification et compaction compris. Les durées portées par les cartes ne
+// disent que la génération de texte ; celle-ci répond à « ça a pris combien de
+// temps, en tout ? ».
+function paintTurnClock(){
+ const composer = document.getElementById('composer');
+ if(!composer || !T.startTs) return;
+ let el = document.getElementById('turn-clock');
+ if(!el){
+ el = document.createElement('div');
+ el.id = 'turn-clock';
+ el.className = 'composer-banner';
+ composer.insertBefore(el, composer.firstChild);
+ }
+ const ms = (T.doneTs || nowServer()) - T.startTs;
+ if(!(ms > 0)){ el.remove(); return; }
+ el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms);
+ el.classList.toggle('done', !!T.doneTs);
+}
// 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.
@@ -261,7 +282,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){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
+ if(d.reset!==undefined){ stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -280,7 +301,7 @@ function handleDelta(d){
// 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; }
+ setBusy(true); startWorkTimer(); paintTurnClock(); 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.
@@ -290,6 +311,7 @@ function handleDelta(d){
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
+ paintTurnClock(); // fige le total au-dessus de la carte
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; }
diff --git a/internal/loki/ui/src/js/20-mode.js b/internal/loki/ui/src/js/20-mode.js
index c4a919e..40fd94e 100644
--- a/internal/loki/ui/src/js/20-mode.js
+++ b/internal/loki/ui/src/js/20-mode.js
@@ -51,14 +51,28 @@ async function modeOnConvChange(){
// Émise par le serveur (delta code_hint) quand un message de mode chat
// ressemble à une tâche de code. Ignorable, jamais bloquante.
+// Insère un bandeau DANS le composeur, au-dessus de la barre de saisie.
+// #composer est en position:absolute : un élément posé AVANT lui dans le DOM
+// reste dans le flux normal de la page et se retrouve donc CACHÉ dessous. Ses
+// enfants, eux, suivent sa colonne (--col) et son empilement.
+function composerBanner(id){
+ const composer = document.getElementById('composer');
+ if(!composer) return null;
+ let el = document.getElementById(id);
+ if(!el){
+ el = document.createElement('div');
+ el.id = id;
+ el.className = 'composer-banner';
+ composer.insertBefore(el, composer.firstChild);
+ }
+ return el;
+}
+
function showCodeHint(){
if(CURRENT_MODE==='code' || document.getElementById('code-hint')) return;
- const composer = document.getElementById('composer');
- if(!composer) return;
- const chip = document.createElement('div');
- chip.id = 'code-hint';
+ const chip = composerBanner('code-hint');
+ if(!chip) return;
chip.innerHTML = 'ça ressemble à une tâche de code — passer en mode Code ✕ ';
- composer.parentNode.insertBefore(chip, composer);
chip.querySelector('#code-hint-yes').onclick = ()=>{ setMode('code'); };
chip.querySelector('#code-hint-no').onclick = hideCodeHint;
}
@@ -76,14 +90,15 @@ function renderCriteria(list){
let box = document.getElementById('criteria-box');
if(!list || !list.length){ if(box) box.remove(); return; }
if(CURRENT_MODE!=='code'){ if(box) box.remove(); return; }
- if(!box){
- const composer = document.getElementById('composer');
- if(!composer) return;
- box = document.createElement('div');
- box.id = 'criteria-box';
+ const fresh = !box;
+ box = composerBanner('criteria-box');
+ if(!box) return;
+ if(fresh){
box.innerHTML = '
';
- composer.parentNode.insertBefore(box, composer);
box.querySelector('#criteria-head').onclick = ()=> box.classList.toggle('folded');
+ // Repliée par défaut : cinq critères dépliés mangeaient la moitié de
+ // l'écran au-dessus de la saisie. L'en-tête porte déjà le compteur.
+ box.classList.add('folded');
}
box.style.display='';
const passed = list.filter(c=>c.status==='passed').length;
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index b667010..5a830dd 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -1632,12 +1632,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
é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}
+/* L'animation porte sur le CONTENEUR (élément HTML), pas sur le : sur un
+ élément SVG, l'origine de la rotation dépend de transform-box, dont le défaut
+ a changé au fil des versions — l'anneau restait figé chez certains. Un span
+ inline-flex tourne partout, sans ambiguïté. Les deux propriétés restent
+ posées sur le svg pour le cas où une règle le viserait encore. */
+.conv-spin{animation:conv-spin 900ms linear infinite;transform-origin:center}
+.conv-spin svg{transform-box:fill-box;transform-origin:center}
@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}
+ .conv-spin{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
@@ -1971,17 +1977,30 @@ html[data-files="1"] #files-btn{color:var(--accent)}
#mode-seg button.on{background:var(--accent-bg);color:var(--accent)}
#mode-seg button:not(.on):hover{color:var(--text)}
-#code-hint{display:flex;align-items:center;gap:8px;margin:0 auto 6px;max-width:820px;padding:6px 10px;border:1px solid var(--border);border-left:2px solid var(--accent);border-radius:3px;background:var(--panel);color:var(--dim);font-size:12px}
+/* Bandeaux du composeur (chrono du tour, puce mode code, critères). Ils vivent
+ DANS #composer — qui est en position:absolute — et suivent donc sa colonne
+ (--col). Posés avant lui dans le DOM, ils restaient dans le flux de la page
+ et disparaissaient SOUS la carte de saisie. */
+.composer-banner{margin:0 var(--col) 6px;padding:6px 12px;border:1px solid var(--border);border-radius:4px;background:var(--panel);color:var(--dim);font-size:12px;font-family:var(--mono)}
+#turn-clock{display:flex;align-items:center;gap:6px;letter-spacing:.03em}
+#turn-clock::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--accent);animation:tcpulse 1.4s ease-in-out infinite}
+#turn-clock.done{color:var(--accent)}
+#turn-clock.done::before{animation:none;opacity:.6}
+@keyframes tcpulse{0%,100%{opacity:.25}50%{opacity:1}}
+
+#code-hint{display:flex;align-items:center;gap:8px;font-size:12px}
#code-hint button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit}
#code-hint button:hover{background:var(--accent-bg)}
#code-hint #code-hint-no{border-color:var(--border);color:var(--dim);margin-left:auto}
-#criteria-box{margin:0 auto 6px;max-width:820px;border:1px solid var(--border);border-radius:3px;background:var(--panel);font-size:12px;overflow:hidden}
-#criteria-box.allpass{border-left:2px solid var(--accent)}
-#criteria-head{padding:5px 10px;color:var(--dim);cursor:pointer;user-select:none;letter-spacing:.03em}
+#criteria-box{padding:0;overflow:hidden;max-height:38vh;overflow-y:auto}
+#criteria-box.allpass{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))}
+#criteria-head{padding:6px 12px;color:var(--dim);cursor:pointer;user-select:none;letter-spacing:.03em;position:sticky;top:0;background:var(--panel)}
+#criteria-head::after{content:' ▾';opacity:.5}
+#criteria-box.folded #criteria-head::after{content:' ▸'}
#criteria-box.allpass #criteria-head{color:var(--accent)}
#criteria-box.folded #criteria-list{display:none}
-#criteria-list{list-style:none;margin:0;padding:0 10px 6px}
+#criteria-list{list-style:none;margin:0;padding:0 12px 8px}
#criteria-list li{padding:2px 0;color:var(--text)}
#criteria-list li.crit-passed{color:var(--accent)}
#criteria-list li.crit-failed{color:var(--err,#b3564d)}