From 6efd2a362e86f960cd45a04fb3cc0e47c939c494 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 18:35:04 +0000 Subject: [PATCH] =?UTF-8?q?Barre=20de=20saisie=20coh=C3=A9rente=20et=20?= =?UTF-8?q?=C3=A9tiquettes=20d'identit=C3=A9=20stables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux retours, deux vrais défauts. Boutons de la saisie Ils avaient trois générations de règles empilées — pilule large « Envoyer », aplat noir, aplat sauge — si bien que chaque bouton tirait sa taille, sa forme et son alignement d'une couche différente : rond pour l'envoi et l'arrêt, carré de 15 px pour l'icône stop contre 16 pour ses voisines, et un align-self:center sur deux boutons seulement (les deux autres tombaient au bas de la pilule). Une seule règle les décrit désormais : 34x34, coins doux de 10 px — jamais un rond, la maquette n'en a pas —, icône de 18 px centrée, fond seulement au survol. Seule la couleur les distingue : atténué pour joindre et fichiers, sauge pour envoyer, rouille pour arrêter. Étiquettes des bulles Une bulle changeait de nom en cours de route. Envoyée en direct, elle s'appelait « USER » (confirmPending réécrivait l'étiquette en dur) ; rejouée au chargement, elle portait l'avatar et le prénom. Même chose côté réponse : labelTokens remplaçait « Loki » par « assistant · 75 tok · 21.5 tok/s » pendant la génération, et le nom ne revenait qu'au rechargement de la page. L'étiquette porte maintenant l'identité DANS TOUS LES CAS ; les mesures de génération vont dans la ligne dédiée, à l'endroit exact où les stats de fin de tour les écrivent déjà. L'envoi en cours se lit à la bulle grisée et à son infobulle, plus à un libellé qui écrase le prénom. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd --- internal/loki/ui/index.html | 73 ++++++++++++++++------------ internal/loki/ui/src/index.tmpl.html | 8 +-- internal/loki/ui/src/js/09-stream.js | 21 ++++++-- internal/loki/ui/src/styles.css | 44 ++++++++--------- 4 files changed, 86 insertions(+), 60 deletions(-) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index f5825c2..3a924b9 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -162,7 +162,6 @@ option,optgroup{background:var(--panel);color:var(--text)} .msg{max-width:92%} #chat .msg.assistant,#chat .msg.reasoning{max-width:100%} #inputbar{padding:8px;gap:4px} - #send,#stop{padding:0 12px} /* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et ça prend de la place. Elle reste visible sur desktop. */ #sendhint{display:none} @@ -675,7 +674,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} #sendhint.waiting{opacity:1;color:var(--dim)} #input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden} #input:focus{outline:none;border-color:var(--accent)} -#send{padding:0 20px} .muted{color:var(--dim);font-size:13px} #toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)} #toast.show{opacity:1} @@ -707,7 +705,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} /* PAS de font-size < 16px ici : iOS Safari zoome à la sélection du champ. On garde 16px (hérité de la règle anti-zoom) et on compacte via hauteur/padding. */ #input{min-height:34px;max-height:120px;padding:6px 9px} - #send,#stop{padding:0 14px} #composer .muted{display:none} #ctxmeta{padding:3px 10px 0} h1{font-size:15px} @@ -1105,9 +1102,6 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} /* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même bordure, seule la saisie change (curseur). */ #input:focus{border-color:transparent} -#send,#stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0} -#send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600} -#send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)} /* Pied de carte : c'est LUI qui porte l'arrondi bas (26 px, comme en haut) et qui clippe la jauge. Un bandeau de 7 px ne peut pas dessiner un rayon de 26 px (le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte @@ -1318,10 +1312,6 @@ button:hover{border-color:var(--dim);color:var(--text)} /* Action principale en sauge : c'est la couleur d'action de la charte, et elle se distingue enfin du texte (le bouton reprenait --text, donc un brun sombre indistinct du reste). L'arrêt, lui, reste en terre cuite. */ -#send{background:var(--accent);border-color:var(--accent);color:var(--on-accent)} -#send:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:var(--on-accent);opacity:.88} -#stop{background:var(--err);border-color:var(--err);color:var(--on-err)} -#stop:hover{background:var(--err);border-color:var(--err);color:var(--on-err);opacity:.88} /* --- Terre : largeur de lecture -------------------------------------- La mesure était plafonnée à 760 px et centrée sur la fenêtre : sur un écran @@ -1622,23 +1612,33 @@ button:active:not(:disabled){transform:translateY(1px)} maquette, et elle laisse toute la largeur au texte. Le mot « Envoyer » reste porté par aria-label et par l'infobulle — l'action n'est pas moins nommée pour un lecteur d'écran, elle l'est moins à l'écran. */ -#send,#stop{width:34px;height:34px;min-height:34px;padding:0;border-radius:999px; - display:inline-flex;align-items:center;justify-content:center;flex:none} -#send svg,#stop svg{display:block} -#send{background:transparent;border-color:transparent;color:var(--accent)} -#send:hover:not(:disabled){opacity:1;transform:none;background:var(--accent-bg);color:var(--accent)} -#send:active:not(:disabled){transform:scale(.92)} -#send:disabled{background:transparent;color:var(--dim)} -#stop{background:transparent;border-color:transparent;color:var(--err)} -#stop:hover{opacity:1;background:var(--danger-bg);color:var(--err)} +/* Les QUATRE boutons de la barre de saisie — joindre, fichiers, envoyer, + arrêter — sont un seul et même objet : 34x34, coins doux (jamais un rond : + la maquette n'en a pas), icône de 18 px centrée, fond seulement au survol. + Seule la couleur les distingue : atténué pour les secondaires, sauge pour + l'envoi, rouille pour l'arrêt. + ⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées + (pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa + taille, sa forme et son alignement d'une couche différente. */ +#inputbar #attach,#inputbar #files-btn,#send,#stop{ + width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0; + display:inline-flex;align-items:center;justify-content:center;align-self:center; + border:0;border-radius:10px;background:transparent;color:var(--dim); + transition:background .15s ease,color .15s ease,transform .08s ease} +#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px} +#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)} +html[data-files="1"] #files-btn{color:var(--accent)} +#send{color:var(--accent)} +#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none} +#send:disabled{background:transparent;color:var(--dim);opacity:.45} +#stop{color:var(--err)} +#stop:hover{background:var(--danger-bg);color:var(--err);opacity:1} +#inputbar #attach:active,#inputbar #files-btn:active,#send:active:not(:disabled),#stop:active{transform:scale(.92)} /* Les outils de la saisie (joindre, fichiers, envoyer) montent DANS la pilule, à droite du texte : c'est là qu'on les cherche, et le pied de carte redevient ce qu'il doit être — une jauge de contexte, rien d'autre. */ #inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow-card)} #composer:focus-within #inputbar,#composer:focus-within #ctxmeta{border-color:var(--accent-soft)} -#inputbar #attach,#inputbar #files-btn{width:34px;height:34px;min-height:34px;border-radius:999px; - align-self:center} -#inputbar #attach svg,#inputbar #files-btn svg{width:16px;height:16px} #ctxmeta{padding-bottom:12px} #ctxtrack{border-radius:999px} @@ -2086,22 +2086,22 @@ button:active:not(:disabled){transform:translateY(1px)}
Entrée pour envoyer · Maj+Entrée = nouvelle ligne
@@ -5177,7 +5177,10 @@ function addPending(text){ clearPending(); PENDING=addMsg('user', text); PENDING.classList.add('pending'); - const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…'; + // L'étiquette garde l'avatar et le prénom (paintLabel, posé par addMsg) : une + // bulle ne doit JAMAIS s'appeler autrement d'un tour à l'autre. L'envoi en + // cours se lit à la bulle grisée (.pending) et à l'infobulle. + const l=PENDING.querySelector('.label'); if(l) l.title='envoi en cours…'; jumpBottom(); return PENDING; } @@ -5188,7 +5191,11 @@ function confirmPending(text){ const b=PENDING.querySelector('.body'); if(!b || b.textContent!==text) return false; PENDING.classList.remove('pending'); - const l=PENDING.querySelector('.label'); if(l) l.textContent='user'; + // ⚠️ NE PAS réécrire l'étiquette ici : elle porte l'avatar et le prénom + // (17-identity.js). Elle y était remise à « user », si bien que le message + // qu'on venait d'envoyer s'affichait « USER » alors que ceux rejoués au + // chargement portaient le prénom — deux bulles identiques, deux libellés. + const l=PENDING.querySelector('.label'); if(l) l.removeAttribute('title'); PENDING=null; return true; } @@ -5269,8 +5276,14 @@ function renderStats(el, s){ function labelTokens(el, role, n, firstTs, lastTs){ if(!el) return; const secs=(lastTs-firstTs)/1000; - if(secs>0.05 && n>1){ setLabel(el, role+' · '+n+' tok · '+(n/secs).toFixed(1)+' tok/s'); } - else { setLabel(el, role+' · '+n+' tok'); } + const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok'; + // Bulle technique : son étiquette EST le bouton de repli, les mesures y vont. + // Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures + // vont dans la ligne dédiée — comme le font déjà les stats de fin de tour + // (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct + // par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement. + if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m); + else setStats(el, m); } // Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons // (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible). diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 75daca3..d11413b 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -407,22 +407,22 @@ document.documentElement.setAttribute('data-perf',localStorage.getItem('loki-per
Entrée pour envoyer · Maj+Entrée = nouvelle ligne
diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index e8a96ff..3a0a07a 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -22,7 +22,10 @@ function addPending(text){ clearPending(); PENDING=addMsg('user', text); PENDING.classList.add('pending'); - const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…'; + // L'étiquette garde l'avatar et le prénom (paintLabel, posé par addMsg) : une + // bulle ne doit JAMAIS s'appeler autrement d'un tour à l'autre. L'envoi en + // cours se lit à la bulle grisée (.pending) et à l'infobulle. + const l=PENDING.querySelector('.label'); if(l) l.title='envoi en cours…'; jumpBottom(); return PENDING; } @@ -33,7 +36,11 @@ function confirmPending(text){ const b=PENDING.querySelector('.body'); if(!b || b.textContent!==text) return false; PENDING.classList.remove('pending'); - const l=PENDING.querySelector('.label'); if(l) l.textContent='user'; + // ⚠️ NE PAS réécrire l'étiquette ici : elle porte l'avatar et le prénom + // (17-identity.js). Elle y était remise à « user », si bien que le message + // qu'on venait d'envoyer s'affichait « USER » alors que ceux rejoués au + // chargement portaient le prénom — deux bulles identiques, deux libellés. + const l=PENDING.querySelector('.label'); if(l) l.removeAttribute('title'); PENDING=null; return true; } @@ -114,8 +121,14 @@ function renderStats(el, s){ function labelTokens(el, role, n, firstTs, lastTs){ if(!el) return; const secs=(lastTs-firstTs)/1000; - if(secs>0.05 && n>1){ setLabel(el, role+' · '+n+' tok · '+(n/secs).toFixed(1)+' tok/s'); } - else { setLabel(el, role+' · '+n+' tok'); } + const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok'; + // Bulle technique : son étiquette EST le bouton de repli, les mesures y vont. + // Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures + // vont dans la ligne dédiée — comme le font déjà les stats de fin de tour + // (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct + // par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement. + if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m); + else setStats(el, m); } // Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons // (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible). diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 095eebb..385bfaf 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -131,7 +131,6 @@ option,optgroup{background:var(--panel);color:var(--text)} .msg{max-width:92%} #chat .msg.assistant,#chat .msg.reasoning{max-width:100%} #inputbar{padding:8px;gap:4px} - #send,#stop{padding:0 12px} /* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et ça prend de la place. Elle reste visible sur desktop. */ #sendhint{display:none} @@ -644,7 +643,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} #sendhint.waiting{opacity:1;color:var(--dim)} #input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden} #input:focus{outline:none;border-color:var(--accent)} -#send{padding:0 20px} .muted{color:var(--dim);font-size:13px} #toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)} #toast.show{opacity:1} @@ -676,7 +674,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} /* PAS de font-size < 16px ici : iOS Safari zoome à la sélection du champ. On garde 16px (hérité de la règle anti-zoom) et on compacte via hauteur/padding. */ #input{min-height:34px;max-height:120px;padding:6px 9px} - #send,#stop{padding:0 14px} #composer .muted{display:none} #ctxmeta{padding:3px 10px 0} h1{font-size:15px} @@ -1074,9 +1071,6 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} /* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même bordure, seule la saisie change (curseur). */ #input:focus{border-color:transparent} -#send,#stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0} -#send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600} -#send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)} /* Pied de carte : c'est LUI qui porte l'arrondi bas (26 px, comme en haut) et qui clippe la jauge. Un bandeau de 7 px ne peut pas dessiner un rayon de 26 px (le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte @@ -1287,10 +1281,6 @@ button:hover{border-color:var(--dim);color:var(--text)} /* Action principale en sauge : c'est la couleur d'action de la charte, et elle se distingue enfin du texte (le bouton reprenait --text, donc un brun sombre indistinct du reste). L'arrêt, lui, reste en terre cuite. */ -#send{background:var(--accent);border-color:var(--accent);color:var(--on-accent)} -#send:hover:not(:disabled){background:var(--accent);border-color:var(--accent);color:var(--on-accent);opacity:.88} -#stop{background:var(--err);border-color:var(--err);color:var(--on-err)} -#stop:hover{background:var(--err);border-color:var(--err);color:var(--on-err);opacity:.88} /* --- Terre : largeur de lecture -------------------------------------- La mesure était plafonnée à 760 px et centrée sur la fenêtre : sur un écran @@ -1591,23 +1581,33 @@ button:active:not(:disabled){transform:translateY(1px)} maquette, et elle laisse toute la largeur au texte. Le mot « Envoyer » reste porté par aria-label et par l'infobulle — l'action n'est pas moins nommée pour un lecteur d'écran, elle l'est moins à l'écran. */ -#send,#stop{width:34px;height:34px;min-height:34px;padding:0;border-radius:999px; - display:inline-flex;align-items:center;justify-content:center;flex:none} -#send svg,#stop svg{display:block} -#send{background:transparent;border-color:transparent;color:var(--accent)} -#send:hover:not(:disabled){opacity:1;transform:none;background:var(--accent-bg);color:var(--accent)} -#send:active:not(:disabled){transform:scale(.92)} -#send:disabled{background:transparent;color:var(--dim)} -#stop{background:transparent;border-color:transparent;color:var(--err)} -#stop:hover{opacity:1;background:var(--danger-bg);color:var(--err)} +/* Les QUATRE boutons de la barre de saisie — joindre, fichiers, envoyer, + arrêter — sont un seul et même objet : 34x34, coins doux (jamais un rond : + la maquette n'en a pas), icône de 18 px centrée, fond seulement au survol. + Seule la couleur les distingue : atténué pour les secondaires, sauge pour + l'envoi, rouille pour l'arrêt. + ⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées + (pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa + taille, sa forme et son alignement d'une couche différente. */ +#inputbar #attach,#inputbar #files-btn,#send,#stop{ + width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0; + display:inline-flex;align-items:center;justify-content:center;align-self:center; + border:0;border-radius:10px;background:transparent;color:var(--dim); + transition:background .15s ease,color .15s ease,transform .08s ease} +#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px} +#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)} +html[data-files="1"] #files-btn{color:var(--accent)} +#send{color:var(--accent)} +#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none} +#send:disabled{background:transparent;color:var(--dim);opacity:.45} +#stop{color:var(--err)} +#stop:hover{background:var(--danger-bg);color:var(--err);opacity:1} +#inputbar #attach:active,#inputbar #files-btn:active,#send:active:not(:disabled),#stop:active{transform:scale(.92)} /* Les outils de la saisie (joindre, fichiers, envoyer) montent DANS la pilule, à droite du texte : c'est là qu'on les cherche, et le pied de carte redevient ce qu'il doit être — une jauge de contexte, rien d'autre. */ #inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow-card)} #composer:focus-within #inputbar,#composer:focus-within #ctxmeta{border-color:var(--accent-soft)} -#inputbar #attach,#inputbar #files-btn{width:34px;height:34px;min-height:34px;border-radius:999px; - align-self:center} -#inputbar #attach svg,#inputbar #files-btn svg{width:16px;height:16px} #ctxmeta{padding-bottom:12px} #ctxtrack{border-radius:999px}