diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js
index e98fbf7..561ca7b 100644
--- a/internal/loki/ui/src/js/06-settings.js
+++ b/internal/loki/ui/src/js/06-settings.js
@@ -491,7 +491,7 @@ async function loadAll(){
// allSettled et pas all : un seul chargement en échec (accès distant coupé,
// clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les
// hauteurs réservées en place pour toujours.
- await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]);
+ await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js
index 1f46756..16a91d9 100644
--- a/internal/loki/ui/src/js/08-chat-render.js
+++ b/internal/loki/ui/src/js/08-chat-render.js
@@ -56,7 +56,22 @@ function addMsg(role, text){
}
}
el.querySelector('.body').textContent=text;
- chatEl().appendChild(el);
+ // Bulles techniques (raisonnement, outils, mémoire) : elles se posent CÔTE À
+ // CÔTE dans une rangée qui déborde à la ligne. Un tour d'agent en enchaîne
+ // parfois dix ; empilées, elles repoussaient la réponse hors de l'écran alors
+ // qu'elles ne portent qu'une étiquette tant qu'elles sont repliées. Dépliée,
+ // une bulle reprend toute la largeur de la rangée (CSS .toolrow).
+ if(collapsible){
+ let row = chatEl().lastElementChild;
+ if(!row || !row.classList.contains('toolrow')){
+ row = document.createElement('div');
+ row.className = 'toolrow';
+ chatEl().appendChild(row);
+ }
+ row.appendChild(el);
+ } else {
+ chatEl().appendChild(el);
+ }
scrollMaybe();
return el;
}
diff --git a/internal/loki/ui/src/js/13-remote.js b/internal/loki/ui/src/js/13-remote.js
deleted file mode 100644
index 890fa6d..0000000
--- a/internal/loki/ui/src/js/13-remote.js
+++ /dev/null
@@ -1,96 +0,0 @@
-// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal.
-// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte +
-// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à
-// l'agent local (/api/link/connect) qui fait le `loki link` (token + service).
-
-const LOKI_APP_ORIGIN = 'https://app.ajean.link';
-
-function renderRemote(d){
- const off = document.getElementById('remote-off'), on = document.getElementById('remote-on');
- const badge = document.getElementById('remote-badge');
- if(!d || !d.linked){
- // Serveur jamais lié : on l'affiche explicitement plutôt que de laisser la
- // pastille vide, qui se lisait comme « je ne sais pas ».
- off.style.display=''; on.style.display='none';
- setBadge(badge, false, 'non connecté');
- return;
- }
- off.style.display='none'; on.style.display='';
- document.getElementById('remote-url').value = d.machineURL || '';
- const st = document.getElementById('remote-status');
- if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; }
- else { st.textContent='○ tunnel arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; }
- const sb = document.getElementById('remote-start');
- if(sb){ sb.style.display = d.active ? 'none' : ''; }
- setBadge(badge, d.active ? true : 'warn', d.active ? 'connecté' : 'arrêté');
-}
-
-async function loadRemote(){
- // Le panneau ne vaut qu'en LOCAL : il pilote /api/link/* que le tunnel bloque
- // (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache.
- const det = document.getElementById('remote-details');
- if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; }
- // Statut injoignable : on retombe sur « non connecté » plutôt que de garder
- // l'affichage précédent, qui pourrait annoncer « connecté » à tort.
- try{ renderRemote(await jget('/api/link/status')); }catch(e){ renderRemote(null); }
-}
-
-// Ouvre la popup de connexion et attend la clé renvoyée par postMessage.
-function remoteConnect(){
- const params = new URLSearchParams({ origin: window.location.origin, host: window.location.hostname || 'ce serveur' });
- const url = LOKI_APP_ORIGIN + '/connect.html?' + params.toString();
- const pop = window.open(url, 'loki-connect', 'width=440,height=640');
- if(!pop){ toast('autorisez les popups pour connecter ajean.link'); return; }
-
- async function onMsg(ev){
- // Anti-usurpation : n'accepte QUE des messages du portail ajean.link.
- if(ev.origin !== LOKI_APP_ORIGIN) return;
- const d = ev.data || {};
- if(d.type !== 'loki-link' || !d.token) return;
- window.removeEventListener('message', onMsg);
- try{
- const r = await jpost('/api/link/connect', { token: d.token });
- if(r && r.linked){
- toast('✓ accès distant connecté');
- if(r.serviceErr){ toast('token enregistré (service : '+r.serviceErr+')'); }
- loadRemote();
- } else {
- toast((r && r.error) || 'échec de la connexion');
- }
- }catch(e){ toast('erreur : '+e); }
- }
- window.addEventListener('message', onMsg);
-}
-
-// Relance le tunnel avec la clé déjà enregistrée (sans repasser par la popup).
-async function remoteStart(){
- const b = document.getElementById('remote-start');
- if(b){ b.disabled = true; b.textContent = 'démarrage…'; }
- try{
- const r = await jpost('/api/link/start', {});
- if(r && r.active){ toast('✓ tunnel démarré'); }
- else { toast((r && r.error) || 'le tunnel n\'a pas démarré'); }
- }catch(e){ toast('erreur : '+e); }
- if(b){ b.disabled = false; b.textContent = 'démarrer le tunnel'; }
- loadRemote();
-}
-
-async function remoteDisconnect(){
- const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'});
- if(!ok) return;
- try{ await jpost('/api/link/disconnect', {}); toast('accès distant coupé'); loadRemote(); }
- catch(e){ toast('erreur : '+e); }
-}
-
-async function remotePairCode(){
- const box = document.getElementById('remote-pair');
- box.style.display=''; box.textContent='génération du code…';
- try{
- const r = await jpost('/api/link/paircode', {});
- if(r && r.code){
- box.innerHTML = 'Empreinte :
'+(r.fingerprint||'—')+'Code d\'appairage (valable 10 min, usage unique) :
'+r.code+'';
- } else {
- box.textContent = (r && r.error) || 'code indisponible';
- }
- }catch(e){ box.textContent='erreur : '+e; }
-}
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index 44e98dd..400a67e 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -1318,3 +1318,32 @@ button:hover{border-color:var(--dim);color:var(--text)}
#chat{padding:26px max(20px,calc(50% - var(--measure)/2)) 8px}
#composer{--col:calc(max(20px,calc(50% - var(--measure)/2)) + var(--sbw,0px))}
}
+
+/* --- Terre : rangée de bulles techniques ------------------------------
+ Raisonnement, outils et mémoire ne portent qu'une étiquette tant qu'ils sont
+ repliés : les empiler gaspillait une ligne entière par appel et repoussait la
+ réponse hors de l'écran. Ils défilent donc en rangée, et reprennent la pleine
+ largeur dès qu'on en déplie un. */
+.toolrow{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start}
+.toolrow>.msg.collapsible{margin:0;max-width:100%}
+.toolrow>.msg.collapsible.collapsed{width:auto}
+/* Dépliée : la bulle passe seule sur sa ligne, sinon son contenu serait
+ comprimé dans une colonne étroite à côté d'une pastille. */
+.toolrow>.msg.collapsible:not(.collapsed){flex:1 0 100%}
+
+/* --- Terre : sections groupées en cartes ------------------------------
+ Une carte par section empilait onze arrondis et autant de gouttières : la
+ barre latérale débordait de l'écran alors que la plupart de ces lignes sont
+ des titres repliés d'une seule ligne. Les sections d'IA d'un côté, celles de
+ l'application de l'autre, tiennent maintenant chacune dans UNE carte, avec un
+ filet discret entre les lignes. */
+@media (min-width:721px){
+ .sidegroup{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
+ padding:2px 16px;display:flex;flex-direction:column}
+ /* Dans un groupe, les sections ne sont plus des cartes : elles redeviennent
+ des lignes, séparées par un filet — sauf la première. */
+ .sidegroup>details{background:transparent;box-shadow:none;border-radius:0;padding:0;
+ border-top:1px solid var(--border)}
+ .sidegroup>details:first-child{border-top:0}
+ .sidegroup>details[open]{padding-bottom:12px}
+}