diff --git a/conversation.go b/conversation.go
index cd69cd1..e4d299c 100644
--- a/conversation.go
+++ b/conversation.go
@@ -261,6 +261,7 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
c.mu.Lock()
last := from
epoch := c.epoch
+ announced := false // a-t-on signalé la fin du replay (caught_up) ?
for {
if ctx.Err() != nil {
c.mu.Unlock()
@@ -304,6 +305,18 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
if sent {
continue // il peut y avoir eu de nouveaux événements pendant l'envoi
}
+ // Replay drainé : on le signale UNE fois au client (il replie alors
+ // instantanément les vieilles bulles, sans animation), avant de suivre le
+ // direct. Événement synthétique, non journalisé.
+ if !announced {
+ announced = true
+ c.mu.Unlock()
+ if !emit(map[string]any{"caught_up": true}) {
+ return
+ }
+ c.mu.Lock()
+ continue
+ }
c.cond.Wait() // rien de neuf : dort jusqu'au prochain Broadcast (ou ctx annulé)
}
}
diff --git a/ui/index.html b/ui/index.html
index db52a30..6fe9999 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -335,9 +335,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
@@ -1255,6 +1253,17 @@ function expandBody(el){
function toggleCollapse(el){ el.classList.contains('collapsed') ? expandBody(el) : collapseBody(el); }
// Replie toutes les bulles d'un tour une fois la réponse finale entamée.
function collapseAll(list){ for(const el of list){ if(el) collapseBody(el); } list.length=0; }
+// Replie une bulle INSTANTANÉMENT (sans animation) — utilisé pendant le replay au
+// chargement pour que les vieilles bulles apparaissent déjà fermées. La classe
+// 'collapsed' seule ne gère que l'opacité ; la hauteur est en style inline, donc
+// on la met à 0 transition désactivée.
+function collapseInstant(el){
+ const bw=el.querySelector('.bodywrap'); if(!bw) return;
+ el.classList.add('collapsed');
+ const t=bw.style.transition; bw.style.transition='none';
+ bw.style.height='0px'; bw.style.width='0px';
+ void bw.offsetHeight; bw.style.transition=t;
+}
function setLabel(el, text){ el.querySelector('.label').textContent = text; }
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
@@ -1384,6 +1393,11 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
let lastSeq=0, streamAbort=null;
+// REPLAYING = on est dans le replay initial (rejeu du journal au chargement).
+// Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées →
+// pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up}
+// quand le replay est fini, on repasse alors en direct.
+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 }; }
@@ -1421,6 +1435,7 @@ function setBusy(on){ busy=on;
// piloté par le serveur et rejouable à l'identique.
function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
+ if(d.caught_up){ REPLAYING=false; return; }
if(d.reset!==undefined){ document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
if(d.user!==undefined){ newTurn(); addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
@@ -1431,7 +1446,7 @@ function handleDelta(d){
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
killTyping(); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
- if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); T.turnCollapsibles.push(T.pendingToolEl); }
+ if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
renderToolMsg(T.pendingToolEl, tu);
if(!tu.done) showTyping();
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
@@ -1441,7 +1456,7 @@ function handleDelta(d){
return; }
if(d.reasoning_content){
killTyping();
- if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
+ if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
showTyping(); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason);
if(!T.reasonFirstTs) T.reasonFirstTs=d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok++;
labelTokens(T.reasonEl, 'reasoning', T.reasonTok, T.reasonFirstTs, T.reasonLastTs);