Files
Loki/internal/loki/ui/src/js/16-conversations.js
T
Claude f99e5b59b8 Refonte de l'interface : direction « Sober Tech »
Reprise du design d'après la maquette fournie, sans changer d'architecture :
l'UI reste du HTML/CSS/JS assemblé dans le binaire (voir plus bas).

Charte
- Palette ardoise + sauge en remplacement des bruns « Terre ». Deux variantes :
  claire par défaut (celle de la maquette) et « Deep Dark » (fond #0F172A,
  cartes #1E293B), à un clic depuis l'en-tête. Une variable --on-accent porte ce
  qui s'écrit sur les aplats de sauge, pour que le contraste tienne dans les
  deux variantes.
- Typographie Inter (interface) + JetBrains Mono (code, chiffres, chemins), en
  sous-ensemble latin, embarquées comme l'étaient Bricolage et IBM Plex Mono —
  toujours aucune requête vers un service de polices.
- Boutons sans contour, fond au survol, léger enfoncement au clic.
- Coque à plat : plus de cartes flottantes séparées par une gouttière, la barre
  latérale est collée au bord et le fil occupe tout le reste.

Coque
- Barre d'en-tête : titre de la discussion + sélecteur de modèle. Changer de
  preset demandait d'ouvrir la barre latérale et de descendre jusqu'aux
  presets ; c'est désormais une liste déroulante, toujours visible.
- Barre latérale en trois zones — marque, contenu défilant, moniteur machine —
  et escamotable d'un clic sur grand écran. Les discussions y passent au
  premier plan, avec une recherche textuelle (filtrage côté client : la liste
  est déjà chargée, sans les messages) ; les réglages descendent sous un repli
  unique, d'où openDetails(), qui déplie aussi les parents.
- Moniteur machine en pied de colonne : une jauge par ressource (charge GPU,
  puis VRAM et température en détail, mémoire vive), sauge jusqu'à 75 %, ambre
  puis rouille quand la machine sature. Il remplace la section « Machine », qui
  disait la même chose en plus verbeux et en plus loin.
- L'option « barre latérale escamotable » disparaît d'Apparence : elle faisait
  de la barre un tiroir posé SUR la conversation, là où le bouton d'en-tête
  l'escamote pour de bon. Deux mécanismes concurrents pour la même intention.

Fil et saisie
- La réponse de l'IA redevient une carte, la question un aplat de sauge : sur un
  fond de fil coloré, du texte nu flottait sans ancrage.
- Saisie en pilule, ses outils (joindre, fichiers, envoyer) dans la barre
  elle-même. L'envoi est une pastille ronde à flèche ; le mot « Envoyer » reste
  porté par aria-label et l'infobulle.

Corrections croisées
- L'heure d'une discussion prenait la moitié de la ligne (flex:1 hérité de
  `.preset>span`), le titre était tronqué au premier mot.
- Une liste déroulante posée directement dans une ligne de modale débordait
  sous son intitulé quand sa valeur était longue (chemin de destination).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 16:17:59 +00:00

136 lines
5.6 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 = '';
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
CONV_ACTIVE = r.active || '';
renderConversations();
}
function renderConversations(){
const cont = document.getElementById('conv-list');
if(!cont) return;
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
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();
return;
}
for(const c of list){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
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.textContent = '✎';
ren.title = 'renommer'; ren.onclick = e => { e.stopPropagation(); convRename(c.id, title); };
const del = document.createElement('button'); del.className = 'iconbtn'; del.textContent = '🗑';
del.title = 'supprimer'; del.onclick = e => { e.stopPropagation(); convDelete(c.id, title); };
acts.appendChild(ren); acts.appendChild(del);
row.append(info, when, acts);
cont.appendChild(row);
}
syncTopbarTitle();
}
// 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'); }
}