Files
Loki/internal/loki/ui/src/js/16-conversations.js
T
MichaelandClaude Opus 5.5 04137411fa Discussions : la liste se dessine par pages de 40
Repris d'AJEAN 0.15.6, pour la partie qui nous concerne. Côté serveur, la
liste était déjà légère (l'index ne porte que des métadonnées, aucune
conversation n'est relue) ; c'est le DOM qui coûtait : des centaines de
lignes construites d'un coup à chaque rafraîchissement de la liste.

On en dessine 40, puis la suite quand la ligne « afficher N de plus »
arrive à l'écran (IntersectionObserver, ou clic). La discussion active
reste toujours visible, même au-delà. La recherche filtre toujours la
liste ENTIÈRE, qui reste en mémoire ; changer la requête repart de la
première page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:05:34 +02:00

237 lines
10 KiB
JavaScript

// Discussions — plusieurs fils au lieu de la conversation unique d'origine.
//
// Le fil actif est possédé par le SERVEUR et partagé par tous les appareils :
// basculer ici bascule partout. Le changement arrive donc par le flux SSE (un
// événement `reset`, comme pour « vider »), et c'est lui qui redessine le chat —
// on ne fait que rafraîchir la liste.
//
// Les titres viennent du premier message de l'utilisateur : ils sont construits
// en DOM (jamais en innerHTML) pour qu'un titre contenant < > & " ne casse ni le
// balisage ni les gestionnaires d'événement.
//
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
// 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;
// Liste paginée (AJEAN 0.15.6) : avec des centaines de discussions, construire
// toutes les lignes d'un coup figeait l'ouverture du panneau. On en dessine
// CONV_PAGE, puis la suite quand le bas de la liste devient visible. La
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
// fichier d'index pour les créations dans la même seconde.
const ord = new Map(CONVS.map((c, i) => [c.id, i]));
CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|| ord.get(b.id) - ord.get(a.id));
CONV_ACTIVE = r.active || '';
CONV_BUSY = !!r.busy;
renderConversations();
}
function renderConversations(){
const cont = document.getElementById('conv-list');
if(!cont) return;
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
if(!list.length){
const e = document.createElement('div');
e.className = 'conv-empty';
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
cont.appendChild(e);
syncTopbarTitle();
convSyncBusy();
return;
}
// La discussion active reste toujours visible, même loin dans la liste.
const ai = list.findIndex(c => c.id === CONV_ACTIVE);
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
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';
const nm = document.createElement('div'); nm.className = 'preset-name';
const title = c.title || 'Nouvelle discussion';
nm.appendChild(document.createTextNode(title));
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
info.appendChild(nm);
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
// donc jamais et rien ne se chevauche.
const when = document.createElement('span');
when.className = 'conv-when';
when.textContent = convWhen(c.updated);
// Renommer / supprimer : stopPropagation, sinon le clic bascule aussi.
const acts = document.createElement('div'); acts.className = 'conv-acts';
const ren = document.createElement('button'); ren.className = 'iconbtn';
ren.appendChild(icon('pencil', 15)); ren.title = 'renommer';
ren.setAttribute('aria-label', 'renommer la discussion');
ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
const del = document.createElement('button'); del.className = 'iconbtn';
del.appendChild(icon('trash', 15)); del.title = 'supprimer';
del.setAttribute('aria-label', 'supprimer la discussion');
del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
acts.appendChild(ren); acts.appendChild(del);
row.append(info, when, acts);
cont.appendChild(row);
}
if(list.length > shown) convMoreRow(cont, list.length - shown);
syncTopbarTitle();
convSyncBusy();
}
// Ligne « N de plus » en bas de la liste : un clic, ou son arrivée à l'écran
// (IntersectionObserver), dessine la page suivante.
function convMoreRow(cont, left){
const more = document.createElement('button');
more.type = 'button';
more.className = 'conv-more';
more.textContent = 'afficher ' + Math.min(left, CONV_PAGE) + ' de plus (' + left + ' restantes)';
const next = () => { CONV_SHOWN += CONV_PAGE; renderConversations(); };
more.onclick = next;
cont.appendChild(more);
if(convMoreObs) convMoreObs.disconnect();
if(window.IntersectionObserver){
convMoreObs = new IntersectionObserver(es => { if(es.some(e => e.isIntersecting)){ convMoreObs.disconnect(); next(); } });
convMoreObs.observe(more);
}
}
// --- 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
// quand la barre latérale est escamotée — c'est-à-dire quand on en a besoin.
function syncTopbarTitle(){
const el = document.getElementById('topbar-title');
if(!el) return;
const cur = CONVS.find(c => c.id === CONV_ACTIVE);
const t = (cur && cur.title) || 'Nouvelle discussion';
el.textContent = t;
el.title = t;
}
// Date courte : l'heure pour aujourd'hui, la date sinon — une liste de
// discussions se lit d'un coup d'œil, pas au timestamp complet.
function convWhen(unix){
if(!unix) return '';
const d = new Date(unix * 1000), now = new Date();
const sameDay = d.toDateString() === now.toDateString();
return sameDay
? d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})
: d.toLocaleDateString([], {day:'2-digit', month:'2-digit'});
}
async function convNew(){
try{
await jpost('/api/conversations/new', {});
toast('nouvelle discussion');
const s = document.getElementById('conv-search');
if(s) s.value = ''; // sinon la discussion toute neuve est masquée par le filtre
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
async function convSwitch(id){
try{
const r = await jpost('/api/conversations/switch', {id});
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
async function convRename(id, current){
const t = await askPrompt('Nouveau titre de la discussion', {title:'Renommer', default:current, okText:'Renommer'});
if(!t) return;
try{
const r = await jpost('/api/conversations/rename', {id, title:t});
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
loadConversations();
}catch(_){ toast('erreur réseau'); }
}
async function convDelete(id, title){
// Le geste emporte aussi les fichiers de la discussion : ils lui appartiennent
// (un dossier par discussion côté serveur), autant le dire avant.
if(!await askConfirm('Supprimer « ' + title + ' » ? Son historique ET ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront perdus.',
{title:'Supprimer la discussion', okText:'Supprimer', danger:true})) return;
try{
const r = await jpost('/api/conversations/delete', {id});
if(r && r.ok === false){ toast('erreur : ' + (r.error || '')); return; }
loadConversations();
}catch(_){ toast('erreur réseau'); }
}