From e28bc45557192f5eedd7309ceded80ccfe1060f3 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Tue, 21 Jul 2026 20:28:10 +0200 Subject: [PATCH] UI: pas d'animation de repli au refresh + reorg reglages - serveur emet {caught_up} quand le replay est draine ; le client cree alors les bulles raisonnement/outil DEJA repliees (collapseInstant, sans animation) tant qu'on rejoue le journal -> au refresh elles apparaissent fermees direct - 'Mise a jour' passe au-dessus de 'Actions' - le toggle compactage rejoint la section Parametres (sous le mode agent) --- conversation.go | 13 +++++++++++++ ui/index.html | 35 +++++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 10 deletions(-) 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}
Paramètres
-
-
- +
Apparence @@ -391,6 +389,11 @@ button:disabled{opacity:.5;cursor:not-allowed}
+
Mise à jour +
Version installée : …
+
+
+
Actions
@@ -398,11 +401,6 @@ button:disabled{opacity:.5;cursor:not-allowed}
-
Mise à jour -
Version installée : …
-
-
-
@@ -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);