feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)

Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70

Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work.

Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade.

Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}).

Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant].

Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback.

Workspace panel FOUC fix: [data-workspace-panel] set at parse time.

Docs: docs/ui-ux/index.html + two-stage-proposal.html.

Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification.

Reviewed and approved by @nesquena (independent review). 1361 tests passing.
This commit is contained in:
Aron Prins
2026-04-16 23:04:42 +02:00
committed by GitHub
parent 25d38a467a
commit 9a3dc10d93
20 changed files with 2770 additions and 469 deletions

View File

@@ -40,6 +40,7 @@ function _setWorkspacePanelMode(mode){
if(!layout||!panel)return;
_workspacePanelMode=(mode==='browse'||mode==='preview')?mode:'closed';
const open=_workspacePanelMode!=='closed';
document.documentElement.dataset.workspacePanel=open?'open':'closed';
// Persist open/closed across refreshes (browse/preview → open; closed → closed)
localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');
layout.classList.toggle('workspace-panel-collapsed',!open);

View File

@@ -7,6 +7,7 @@
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<link rel="stylesheet" href="static/style.css">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
@@ -219,53 +220,55 @@
<button class="reconnect-btn" onclick="refreshSession()"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> Reload</button>
</div>
</div>
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
<div class="composer-wrap" id="composerWrap">
<div class="cmd-dropdown" id="cmdDropdown"></div>
<div class="composer-flyout">
<div class="approval-card" id="approvalCard" role="alertdialog" aria-labelledby="approvalHeading" aria-describedby="approvalDesc">
<div class="approval-inner">
<div class="approval-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<span id="approvalHeading" data-i18n="approval_heading">Approval required</span>
</div>
<div class="approval-desc" id="approvalDesc"></div>
<div class="approval-cmd" id="approvalCmd"></div>
<div class="approval-counter" id="approvalCounter" style="display:none;font-size:0.75em;opacity:0.6;margin-top:4px;"></div>
<div class="approval-btns">
<button class="approval-btn once" id="approvalBtnOnce" onclick="respondApproval('once')" title="Allow this one command (Enter)" data-i18n-title="approval_btn_once_title">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_once">Allow once</span>
<kbd class="approval-kbd"></kbd>
</button>
<button class="approval-btn session" id="approvalBtnSession" onclick="respondApproval('session')" title="Allow for this session">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_session">Allow session</span>
</button>
<button class="approval-btn always" id="approvalBtnAlways" onclick="respondApproval('always')" title="Always allow this command pattern">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_always">Always allow</span>
</button>
<button class="approval-btn deny" id="approvalBtnDeny" onclick="respondApproval('deny')" title="Deny — do not run this command">
<span class="approval-btn-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
<span class="approval-btn-label" data-i18n="approval_btn_deny">Deny</span>
</button>
</div>
</div>
</div>
<div class="clarify-card" id="clarifyCard" role="dialog" aria-labelledby="clarifyHeading" aria-describedby="clarifyQuestion clarifyHint">
<div class="clarify-inner">
<div class="clarify-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
<div class="clarify-response">
<input class="clarify-input" id="clarifyInput" type="text" data-i18n-placeholder="clarify_input_placeholder" placeholder="Type your response…">
<button class="clarify-submit" id="clarifySubmit" onclick="respondClarify()" data-i18n="clarify_send">Send</button>
</div>
<div class="clarify-hint" id="clarifyHint" data-i18n="clarify_hint">Pick a choice, or type your own answer below.</div>
</div>
</div>
</div>
<div class="composer-box" id="composerBox">
<div class="drop-hint" id="dropHint">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>

View File

@@ -138,6 +138,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let assistantText='';
let reasoningText='';
let liveReasoningText='';
let assistantRow=null;
let assistantBody=null;
// Thinking tag patterns for streaming display
@@ -182,11 +183,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
inflight.messages.push({role:'assistant',content:assistantText,reasoning:reasoningText||undefined,_live:true,_ts:ts});
persistInflightState();
}
function ensureAssistantRow(){
function ensureAssistantRow(force=false){
if(!_isActiveSession()) return;
if(assistantRow&&!assistantRow.isConnected){assistantRow=null;assistantBody=null;}
if(!force&&!assistantRow){
const parsed=_parseStreamState();
if(!String((parsed&&parsed.displayText)||'').trim()) return;
}
let turn=$('liveAssistantTurn');
if(!turn){
appendThinking();
turn=$('liveAssistantTurn');
}
const blocks=(typeof _assistantTurnBlocks==='function')?_assistantTurnBlocks(turn):null;
if(!blocks) return;
if(!assistantRow){
const existing=$('msgInner').querySelector('.msg-row[data-live-assistant="1"]');
const existing=blocks.querySelector('[data-live-assistant="1"]');
if(existing){
assistantRow=existing;
assistantBody=existing.querySelector('.msg-body');
@@ -197,18 +209,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
return;
}
removeThinking();
const tr=$('toolRunningRow');if(tr)tr.remove();
$('emptyState').style.display='none';
assistantRow=document.createElement('div');assistantRow.className='msg-row';
assistantRow=document.createElement('div');
assistantRow.className='assistant-segment';
assistantRow.setAttribute('data-live-assistant','1');
assistantBody=document.createElement('div');assistantBody.className='msg-body';
const role=document.createElement('div');role.className='msg-role assistant';
const _bn=window._botName||'Hermes';
const icon=document.createElement('div');icon.className='role-icon assistant';icon.textContent=_bn.charAt(0).toUpperCase();
const lbl=document.createElement('span');lbl.style.fontSize='12px';lbl.textContent=_bn;
role.appendChild(icon);role.appendChild(lbl);
assistantRow.appendChild(role);assistantRow.appendChild(assistantBody);
$('msgInner').appendChild(assistantRow);
assistantRow.appendChild(assistantBody);
blocks.appendChild(assistantRow);
}
// ── Shared SSE handler wiring (used for initial connection and reconnect) ──
@@ -244,7 +252,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
function _parseStreamState(){
const raw=assistantText;
if(reasoningText){
return {thinkingText:reasoningText, displayText:_streamDisplay(), inThinking:false};
return {thinkingText:liveReasoningText, displayText:_streamDisplay(), inThinking:false};
}
for(const {open,close} of _thinkPairs){
const trimmed=raw.trimStart();
@@ -299,14 +307,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
assistantText+=d.text;
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
ensureAssistantRow();
const parsed=_parseStreamState();
if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();
_scheduleRender();
});
source.addEventListener('reasoning',e=>{
const d=JSON.parse(e.data);
reasoningText += d.text || '';
liveReasoningText += d.text || '';
syncInflightAssistantMessage();
if(!S.session||S.session.session_id!==activeSid) return;
_scheduleRender();
@@ -327,7 +336,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
persistInflightState();
if(!S.session||S.session.session_id!==activeSid) return;
removeThinking();
// NOTE: don't removeThinking() here — keep the thinking card visible
// above the tool card so the turn reads top-to-bottom as:
// user → thinking → tool cards → response. Removing it caused the card
// to be re-created below everything when reasoning resumed post-tool.
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
liveReasoningText='';
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
appendLiveToolCard(tc);
scrollIfPinned();
@@ -577,7 +591,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(S.session&&S.session.session_id===activeSid){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
S.session=session;S.messages=session.messages||[];
S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);
const hasMessageToolMetadata=S.messages.some(m=>{
if(!m||m.role!=='assistant') return false;
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
return hasTc||hasTu;
});
if(!hasMessageToolMetadata&&session.tool_calls&&session.tool_calls.length){
S.toolCalls=(session.tool_calls||[]).map(tc=>({...tc,done:true}));
}else{
S.toolCalls=[];
}
syncTopbar();renderMessages();
}
renderSessionList();setBusy(false);setComposerStatus('');
@@ -736,12 +761,9 @@ function showApprovalCard(pending, pendingCount) {
const b = $(id); if (b) { b.disabled = false; b.classList.remove("loading"); }
});
card.classList.add("visible");
if (!sameApproval) card.scrollIntoView({block:"nearest", behavior:"smooth"});
// Apply current locale to data-i18n elements inside the card
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
// Focus Allow once button so Enter works immediately
const onceBtn = $("approvalBtnOnce");
if (onceBtn) setTimeout(() => onceBtn.focus(), 50);
if (onceBtn) setTimeout(() => onceBtn.focus({preventScroll: true}), 50);
}
async function respondApproval(choice) {
@@ -970,14 +992,9 @@ function showClarifyCard(pending) {
lockComposerForClarify(question ? `Clarification needed: ${question}` : "Clarification needed");
}
_clarifySetControlsDisabled(false, false);
const msgInner = $("msgInner");
if (msgInner && card.parentElement !== msgInner) {
msgInner.appendChild(card);
}
card.classList.add("visible");
if (!sameClarify) card.scrollIntoView({block:"nearest", behavior:"smooth"});
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
if (input && !sameClarify) setTimeout(() => input.focus(), 50);
if (input && !sameClarify) setTimeout(() => input.focus({preventScroll: true}), 50);
}
async function respondClarify(response) {

View File

@@ -44,35 +44,13 @@ async function loadSession(sid){
S.session=data.session;
S.lastUsage={...(data.session.last_usage||{})};
localStorage.setItem('hermes-webui-session',S.session.session_id);
// B9: sanitize empty assistant messages (PR #402) — build index map to remap
// session-level tool_calls.assistant_msg_idx to the new sanitized positions.
const allMsgs = data.session.messages || [];
const sanitized = [];
const origIdxToSanitizedIdx = {};
let lastKeptAsstIdx = -1;
for (let i = 0; i < allMsgs.length; i++) {
const m = allMsgs[i];
if (!m || !m.role) continue;
if (m.role === 'tool') continue;
if (m.role === 'assistant') {
let c = m.content || '';
if (Array.isArray(c)) c = c.filter(p => p && p.type === 'text').map(p => p.text || '').join('');
if (!String(c).trim().length) { continue; } // empty assistant — skip
lastKeptAsstIdx = sanitized.length;
}
origIdxToSanitizedIdx[i] = sanitized.length;
sanitized.push(m);
}
if (data.session.tool_calls && data.session.tool_calls.length) {
for (const tc of data.session.tool_calls) {
if (!tc || tc.assistant_msg_idx === undefined) continue;
const origIdx = tc.assistant_msg_idx;
tc.assistant_msg_idx = (origIdx in origIdxToSanitizedIdx)
? origIdxToSanitizedIdx[origIdx]
: (lastKeptAsstIdx >= 0 ? lastKeptAsstIdx : -1);
}
}
data.session.messages = sanitized;
data.session.messages = (data.session.messages || []).filter(m => m && m.role);
const hasMessageToolMetadata = (data.session.messages || []).some(m => {
if (!m || m.role !== 'assistant') return false;
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
return hasTc || hasTu;
});
const activeStreamId=data.session.active_stream_id||null;
if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){
const stored=loadInflightState(sid, activeStreamId);
@@ -109,11 +87,14 @@ async function loadSession(sid){
S.messages=data.session.messages||[];
const pendingMsg=typeof getPendingSessionMessage==='function'?getPendingSessionMessage(data.session):null;
if(pendingMsg) S.messages.push(pendingMsg);
// Fix (PR #402): do NOT pre-fill S.toolCalls from session-level tool_calls —
// those have stale assistant_msg_idx values after B9 sanitization. Instead,
// set S.toolCalls=[] and let renderMessages() derive them from per-message
// tool_calls (which already have correct sanitized-array indices).
S.toolCalls=[];
// Prefer reconstructing cards from per-message tool metadata when available.
// Fall back to persisted session summaries for older sessions that only
// saved session.tool_calls and bare role=tool results.
if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length){
S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));
}else{
S.toolCalls=[];
}
clearLiveToolCards();
if(activeStreamId){
S.busy=true;

View File

@@ -304,10 +304,13 @@
.update-btn:hover{background:rgba(124,185,255,0.2);}
.update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);}
.update-btn:disabled{opacity:0.5;cursor:not-allowed;}
/* ── Composer flyout (approval/clarify slide up from behind composer) ── */
.composer-flyout{position:relative;height:0;z-index:1;}
/* ── Approval card ── */
.approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;}
.approval-card.visible{display:block;}
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(233,69,96,0.35);border-radius:14px;padding:16px 18px;}
.approval-card{position:absolute;left:0;right:0;bottom:-24px;max-width:var(--msg-max);margin:0 auto;padding:0 20px;box-sizing:border-box;width:100%;overflow:hidden;pointer-events:none;}
.approval-card.visible{pointer-events:auto;}
.approval-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(124,185,255,0.35);border-radius:14px;padding:16px 18px 40px;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;}
.approval-card.visible .approval-inner{transform:translateY(0);opacity:1;}
.approval-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:13px;font-weight:600;color:#e94560;}
.approval-desc{font-size:12px;color:var(--muted);margin-bottom:8px;line-height:1.5;}
.approval-cmd{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-family:"SF Mono",ui-monospace,monospace;font-size:12px;color:var(--pre-text);white-space:pre-wrap;word-break:break-all;margin-bottom:14px;max-height:120px;overflow-y:auto;}
@@ -329,9 +332,11 @@
.approval-btn.deny:hover{background:rgba(233,69,96,0.12);border-color:rgba(233,69,96,0.7);}
.approval-btn.loading{opacity:.7;cursor:wait;}
/* ── Clarify card ── */
.clarify-card{display:none;max-width:680px;margin:4px 0 2px 40px;padding:0;}
.clarify-card.visible{display:block;}
.clarify-inner{background:rgba(255,255,255,.03);backdrop-filter:blur(8px);border:1px solid rgba(124,185,255,0.16);border-radius:12px;padding:12px 14px 13px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset;}
.clarify-card{position:absolute;left:0;right:0;bottom:-24px;max-width:var(--msg-max);margin:0 auto;padding:0 20px;box-sizing:border-box;width:100%;overflow:hidden;pointer-events:none;max-height:min(calc(100vh - 280px),420px);}
.clarify-card.visible{pointer-events:auto;}
.clarify-card .clarify-inner{max-height:min(calc(100vh - 280px),420px);overflow-y:auto;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;}
.clarify-card.visible .clarify-inner{transform:translateY(0);opacity:1;}
.clarify-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid rgba(124,185,255,0.35);border-radius:12px;padding:12px 14px 36px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset;}
.clarify-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:12px;font-weight:700;color:var(--blue);letter-spacing:.01em;}
.clarify-question{font-size:14px;color:var(--text);line-height:1.7;white-space:pre-wrap;margin-bottom:12px;}
.clarify-choices{display:flex;flex-direction:column;gap:8px;margin-bottom:12px;}
@@ -505,8 +510,8 @@
.suggestion{padding:12px 14px;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;font-size:13px;color:var(--muted);cursor:pointer;transition:all .15s;text-align:left;}
.suggestion:hover{background:rgba(124,185,255,0.07);color:var(--text);border-color:rgba(124,185,255,.3);transform:translateX(2px);}
/* ── Composer ── */
.composer-wrap{border-top:1px solid var(--border);padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
.composer-box{max-width:780px;margin:0 auto;background:var(--input-bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;}
.composer-wrap{padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
.composer-box{max-width:780px;margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;}
.composer-box:focus-within{border-color:rgba(124,185,255,0.5);box-shadow:0 0 0 3px rgba(124,185,255,0.08);}
.composer-wrap.drag-over .composer-box{border-color:var(--blue);background:rgba(124,185,255,0.06);}
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:rgba(124,185,255,0.08);border:2px dashed var(--blue);border-radius:14px;font-size:14px;color:var(--blue);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
@@ -656,6 +661,7 @@
.mobile-overlay{display:none;}
@media(min-width:901px){
html[data-workspace-panel="closed"] .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
.layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;}
}
@@ -735,12 +741,12 @@
.suggestion-grid{max-width:100%!important;}
.suggestion{font-size:12px;padding:10px 12px;}
/* Approval card */
.approval-card{padding:0 10px 8px;}
.approval-card{padding-left:10px;padding-right:10px;}
.approval-btns{gap:6px;}
.approval-btn{padding:8px 12px;font-size:12px;min-height:44px;}
.approval-kbd{display:none;}
/* Clarify card */
.clarify-card{margin:6px 0 4px 0;max-width:100%;}
.clarify-card{padding-left:10px;padding-right:10px;}
.clarify-inner{padding:12px 12px 13px;}
.clarify-response{flex-direction:column;align-items:stretch;}
.clarify-input,.clarify-submit{width:100%;min-height:44px;}
@@ -886,7 +892,8 @@
.msg-role > span{line-height:1;}
/* Composer wrap: slightly less padding on smaller heights */
.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;position:relative;z-index:10;}
.composer-wrap{padding:10px 20px 14px;position:relative;z-index:10;}
.composer-wrap::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(to bottom,transparent,var(--bg));pointer-events:none;}
/* Cron status badges: pill shape refinement */
.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;}
@@ -1027,10 +1034,10 @@ body.resizing{user-select:none;cursor:col-resize;}
.tool-card-icon{font-size:13px;flex-shrink:0;opacity:.8;}
.tool-card-name{font-size:12px;font-weight:600;color:var(--muted);font-family:'SF Mono',ui-monospace,monospace;flex-shrink:0;}
.tool-card-preview{font-size:11px;color:var(--muted);opacity:.6;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;transition:transform .15s;}
.tool-card-toggle{font-size:10px;color:var(--muted);opacity:.5;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
.tool-card.open .tool-card-toggle{transform:rotate(90deg);}
.tool-card-detail{display:none;border-top:1px solid rgba(255,255,255,.06);padding:8px 12px;}
.tool-card.open .tool-card-detail{display:block;}
.tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;border-top:1px solid transparent;padding:0 12px;transition:max-height .22s ease,opacity .18s ease,padding .22s ease,border-top-color .22s ease;}
.tool-card.open .tool-card-detail{max-height:520px;opacity:1;padding:8px 12px;border-top-color:rgba(255,255,255,.06);}
.tool-card-args{margin-bottom:6px;}
.tool-card-args div{font-size:11px;line-height:1.6;}
.tool-arg-key{color:var(--blue);font-family:'SF Mono',ui-monospace,monospace;font-size:11px;}
@@ -1139,11 +1146,267 @@ body.resizing{user-select:none;cursor:col-resize;}
.thinking-card-header{display:flex;align-items:center;gap:6px;padding:6px 12px;cursor:pointer;font-size:12px;color:var(--gold);user-select:none;}
.thinking-card-icon{font-size:14px;}
.thinking-card-label{font-weight:600;letter-spacing:.02em;}
.thinking-card-toggle{margin-left:auto;font-size:10px;transition:transform .15s;}
.thinking-card-toggle{margin-left:auto;font-size:10px;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;}
.thinking-card.open .thinking-card-toggle{transform:rotate(90deg);}
.thinking-card-body{display:none;padding:0 12px 10px;max-height:300px;overflow-y:auto;}
.thinking-card.open .thinking-card-body{display:block;}
.thinking-card-body{display:block;max-height:0;opacity:0;overflow:hidden;padding:0 12px;transition:max-height .22s ease,opacity .18s ease,padding .22s ease;}
.thinking-card.open .thinking-card-body{max-height:300px;opacity:1;padding:0 12px 10px;}
.thinking-card-body pre{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;line-height:1.5;color:var(--muted);white-space:pre-wrap;word-break:break-word;margin:0;}
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}
/* ── CLI / Agent session items in sidebar ── */
.session-item.cli-session {
padding-right: 40px; /* make room for the session actions trigger */
}
.session-item.cli-session::after {
content: attr(data-source);
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--gold);
opacity: .5;
margin-left: auto;
flex-shrink: 0;
pointer-events: none; /* don't block clicks on session-actions beneath */
}
.session-item.cli-session:hover::after {
display: none; /* hide badge on hover so the session menu trigger stays clear */
}
.session-item.cli-session.menu-open::after {
display: none;
}
/* Source-specific colors for gateway sessions */
.session-item.cli-session[data-source="telegram"] { border-left-color: rgba(0, 136, 204, 0.55); }
.session-item.cli-session[data-source="telegram"]::after { color: rgba(0, 136, 204, 0.55); }
.session-item.cli-session[data-source="discord"] { border-left-color: #5865F2; }
.session-item.cli-session[data-source="discord"]::after { color: #5865F2; }
.session-item.cli-session[data-source="slack"] { border-left-color: #4A154B; }
.session-item.cli-session[data-source="slack"]::after { color: #4A154B; }
/* ═══════════════════════════════════════════════════════════════════
Messages redesign — additive overrides for the transcript area.
Fixes the issues catalogued in docs/ui-ux/index.html:
• single indent rail (one var, one column)
• quieter thinking card (was louder than the answer)
• user-message bubble so user vs. assistant reads at a glance
• persistent affordances (timestamps, actions, usage always visible)
• unified widths (body + tool cards share one max)
• tamed inline-code colour (no longer outshouts links)
• streaming cursor at end of live assistant body
• [data-error="1"] marker for error bubbles
• .msg-date-sep for day-change separators
• tighter type scale (11/12/13/14/16 — no more 10/10.5/12.5)
═══════════════════════════════════════════════════════════════════ */
:root {
--msg-rail: 0;
--msg-max: 780px;
--user-bubble-bg: rgba(124,185,255,.05);
--user-bubble-border: rgba(124,185,255,.16);
}
:root[data-theme="light"] { --user-bubble-bg: rgba(45,111,163,.06); --user-bubble-border: rgba(45,111,163,.18); }
:root[data-theme="solarized"] { --user-bubble-bg: rgba(38,139,210,.08); --user-bubble-border: rgba(38,139,210,.22); }
:root[data-theme="monokai"] { --user-bubble-bg: rgba(102,217,232,.06); --user-bubble-border: rgba(102,217,232,.18); }
:root[data-theme="nord"] { --user-bubble-bg: rgba(129,161,193,.08); --user-bubble-border: rgba(129,161,193,.22); }
:root[data-theme="oled"] { --user-bubble-bg: rgba(108,180,255,.05); --user-bubble-border: rgba(108,180,255,.16); }
/* Inline code: stop shouting orange; inherit strong text colour instead */
.msg-body code { color: var(--strong); background: var(--code-inline-bg); font-size: 12.5px; }
/* ── Unified indent rail — every child of a turn lines up on --msg-rail ── */
.msg-row { padding: 12px 0; }
.msg-body { padding-left: var(--msg-rail); padding-top: 8px; max-width: var(--msg-max); }
.msg-body:empty { display: none; }
.assistant-turn { width: 100%; }
.assistant-turn-blocks { display: flex; flex-direction: column; }
.assistant-segment-anchor { display: none; }
/* ── Classic conversation layout: user right, half-width; assistant left ── */
.msg-row[data-role="user"] { align-self: flex-end; max-width: 60%; }
@media (max-width: 900px) { .msg-row[data-role="user"] { max-width: 78%; } }
@media (max-width: 600px) { .msg-row[data-role="user"] { max-width: 90%; } }
/* Hide the entire "empty tool-anchor" assistant row (content='' with
tool_calls). renderMessages keeps it in the DOM so tool cards can anchor
to it, but visually it adds a ghost "Hermes" header above the tool cards.
With the row hidden the transition from live → settled on 'done' is
seamless. */
.msg-row[data-role="assistant"]:has(.msg-body:empty) { padding: 0; margin: 0; }
.msg-row[data-role="assistant"]:has(.msg-body:empty) .msg-role,
.msg-row[data-role="assistant"]:has(.msg-body:empty) .msg-files { display: none; }
.msg-files { padding-left: var(--msg-rail); }
.msg-usage { padding-left: var(--msg-rail); opacity: 1; margin-top: 6px; font-size: 11px; }
.tool-card { margin-left: var(--msg-rail); max-width: var(--msg-max); }
.thinking-card { margin-left: var(--msg-rail); max-width: var(--msg-max); }
.tool-cards-toggle { margin-left: var(--msg-rail); }
.msg-row[data-editing="1"] { width: 100%; }
.msg-row[data-editing="1"] .msg-edit-area,
.msg-row[data-editing="1"] .msg-edit-bar { margin-left: var(--msg-rail); }
/* Quieter, always-visible role header (smaller avatar, always-visible timestamp) */
.msg-role { font-size: 11px; font-weight: 500; margin-bottom: 6px; opacity: .8; letter-spacing: 0; }
.msg-role:hover { opacity: 1; }
.role-icon { width: 20px; height: 20px; font-size: 9px; }
.msg-time { opacity: .65; font-size: 10px; }
.msg-role:hover .msg-time { opacity: 1; }
/* Persistent action toolbar: subtle at rest, full on hover */
.msg-actions { opacity: .25; }
.msg-row:hover .msg-actions { opacity: 1; }
.assistant-turn:hover .msg-actions { opacity: 1; }
/* ── User message: right-aligned bubble; no avatar/label — position identifies sender ── */
.msg-row[data-role="user"] .msg-body {
background: var(--user-bubble-bg);
border: 1px solid var(--user-bubble-border);
border-radius: 14px;
padding: 10px 14px;
margin-left: 0;
padding-left: 14px;
max-width: none;
}
.msg-row[data-role="user"] .msg-files { padding-left: 0; margin-left: 0; justify-content: flex-end; }
.msg-row[data-role="user"][data-editing="1"] .msg-edit-area { background: var(--user-bubble-bg); border-color: var(--user-bubble-border); }
/* Bubble-layout mode: user-card stays intact, just drop the rail margin.
(:has() form matches the existing bubble-layout rule's specificity so this
wins by source order rather than relying on !important.) */
body.bubble-layout .msg-row:has(.msg-role.user) .msg-body { margin-left: 0; padding: 10px 14px; max-width: none; }
body.bubble-layout .msg-row:has(.msg-role.user) .msg-files { margin-left: 0; padding-left: 0; }
body.bubble-layout .msg-row + .msg-row[data-role="user"] { border-top: none; padding-top: 10px; margin-top: 0; }
/* Turn boundary: right alignment already separates user turns — keep only vertical spacing */
.msg-row + .msg-row[data-role="user"] {
border-top: none;
margin-top: 10px;
padding-top: 12px;
}
/* ── Message footer: actions (and user timestamp) sit below the bubble ── */
.msg-foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 4px;
font-size: 11px;
color: var(--muted);
}
.msg-foot .msg-actions { opacity: 1; margin-left: 0; }
.msg-foot .msg-time { font-size: 10.5px; opacity: .75; }
/* User footer: visible only on row hover (bubble identifies sender without needing persistent chrome) */
.msg-row[data-role="user"] .msg-foot {
opacity: 0;
transition: opacity .15s;
padding-right: 2px;
}
.msg-row[data-role="user"]:hover .msg-foot,
.msg-row[data-role="user"]:focus-within .msg-foot { opacity: 1; }
/* Assistant footer: left-aligned under the body rail, subtle at rest */
.msg-row[data-role="assistant"] .msg-foot,
.assistant-turn .msg-foot {
justify-content: flex-start;
padding-left: var(--msg-rail);
max-width: var(--msg-max);
opacity: .45;
transition: opacity .15s;
}
.msg-row[data-role="assistant"]:hover .msg-foot,
.assistant-turn:hover .msg-foot { opacity: 1; }
/* Hide footer while editing to keep the edit bar the only footer-level affordance */
.msg-row[data-editing="1"] .msg-foot { display: none; }
/* Empty tool-anchor rows: hide footer alongside role/files so the row stays invisible */
.msg-row[data-role="assistant"]:has(.msg-body:empty) .msg-foot { display: none; }
/* ── Thinking card: quieter than before (no background panel) but still
clearly a gold-accented affordance so users know it's collapsible. ── */
.thinking-card {
background: rgba(201,168,76,.05);
border: 1px solid rgba(201,168,76,.18);
border-radius: 8px;
padding: 0;
margin: 3px 0 3px var(--msg-rail);
transition: border-color .15s, background .15s;
}
.thinking-card:hover {
border-color: rgba(201,168,76,.3);
background: rgba(201,168,76,.07);
}
.thinking-card-header { padding: 5px 10px; color: var(--gold); font-size: 12px; font-weight: 600; opacity: .85; }
.thinking-card-header:hover { opacity: 1; }
.thinking-card-icon { opacity: .7; }
.thinking-card-body {
max-height: 0;
opacity: 0;
overflow: hidden;
padding: 0 12px;
border-top: 1px solid transparent;
transition: max-height .22s ease, opacity .18s ease, padding .22s ease, border-top-color .22s ease;
}
.thinking-card.open .thinking-card-body { max-height: 260px; opacity: 1; padding: 8px 12px; border-top-color: rgba(201,168,76,.12); }
.thinking-card-body pre { font-size: 11px; line-height: 1.6; color: var(--muted); }
/* ── Tool cards: tighter chrome to match quieter thinking card ── */
.tool-card { border-radius: 8px; margin-top: 3px; margin-bottom: 3px; }
.tool-card-header { padding: 5px 10px; }
.tool-card-name { font-size: 11px; }
.tool-card-preview { font-size: 11px; }
/* ── Streaming cursor at the end of the live assistant body ── */
@keyframes hermes-cursor-blink { 0%,49% { opacity: 1; } 50%,100% { opacity: 0; } }
[data-live-assistant="1"] .msg-body > :last-child::after,
[data-live-assistant="1"] .msg-body:not(:has(> *))::after {
content: '';
display: inline-block;
width: 7px;
height: 1em;
background: var(--blue);
border-radius: 1px;
margin-left: 3px;
vertical-align: -0.16em;
animation: hermes-cursor-blink 1.05s steps(2, start) infinite;
}
/* ── Error state: distinct red-accent card, not italic emphasis ── */
.msg-row[data-error="1"] .msg-body,
.assistant-segment[data-error="1"] .msg-body {
background: rgba(233,69,96,.06);
border: 1px solid rgba(233,69,96,.22);
border-left: 2px solid var(--accent);
border-radius: 8px;
padding: 10px 14px;
margin-left: var(--msg-rail);
max-width: calc(var(--msg-max) - 40px);
color: var(--text);
}
.msg-row[data-error="1"] .msg-body em,
.msg-row[data-error="1"] .msg-body p em,
.assistant-segment[data-error="1"] .msg-body em,
.assistant-segment[data-error="1"] .msg-body p em { font-style: normal; color: inherit; }
.msg-row[data-error="1"] .msg-role,
.assistant-segment[data-error="1"] .msg-role { color: var(--accent); opacity: 1; }
.msg-row[data-error="1"] .role-icon,
.assistant-segment[data-error="1"] .role-icon { background: rgba(233,69,96,.15); color: var(--accent); border-color: rgba(233,69,96,.3); }
/* ── Day-change separator ── */
.msg-date-sep {
display: flex; align-items: center; gap: 10px;
margin: 22px 0 10px; padding: 0 var(--msg-rail);
color: var(--muted); font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: .12em; opacity: .55;
}
.msg-date-sep::before, .msg-date-sep::after { content: ''; flex: 1; height: 1px; background: var(--border); }
/* ── Widths: collapse messages-inner to match content column ── */
.messages-inner { max-width: var(--msg-max); }
@media (min-width: 1400px) { .messages-inner { max-width: calc(var(--msg-max) + 40px); } }
@media (min-width: 1800px) { .messages-inner { max-width: calc(var(--msg-max) + 80px); } }
@media (max-width: 700px) {
.msg-role { margin-bottom: 4px; }
.msg-row[data-role="user"] .msg-body { padding: 8px 12px; }
.msg-row[data-error="1"] .msg-body { padding: 8px 12px; }
}

View File

@@ -843,7 +843,7 @@ function showPromptDialog(opts={}){
function copyMsg(btn){
const row=btn.closest('.msg-row');
const row=btn.closest('[data-raw-text]');
const text=row?row.dataset.rawText:'';
if(!text)return;
navigator.clipboard.writeText(text).then(()=>{
@@ -1075,46 +1075,87 @@ function msgContent(m){
return String(c).trim();
}
function _fmtDateSep(d){
const todayStart=new Date();todayStart.setHours(0,0,0,0);
const dStart=new Date(d);dStart.setHours(0,0,0,0);
const diffDays=Math.round((todayStart-dStart)/86400000);
if(diffDays===0) return 'Today';
if(diffDays===1) return 'Yesterday';
if(diffDays>0 && diffDays<7) return dStart.toLocaleDateString([], {weekday:'long'});
const opts={month:'short', day:'numeric'};
if(todayStart.getFullYear()!==dStart.getFullYear()) opts.year='numeric';
return dStart.toLocaleDateString([], opts);
}
const _ERR_MSG_RE=/^(?:\*\*error\b|error:|connection lost|no response received)/i;
function _messageHasReasoningPayload(m){
if(!m||m.role!=='assistant') return false;
if(m.reasoning) return true;
if(Array.isArray(m.content)) return m.content.some(p=>p&&(p.type==='thinking'||p.type==='reasoning'));
return /<think>[\s\S]*?<\/think>|<\|channel>thought\n[\s\S]*?<channel\|>/.test(String(m.content||''));
}
function _assistantRoleHtml(tsTitle=''){
const _bn=window._botName||'Hermes';
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span></div>`;
}
function _createAssistantTurn(tsTitle=''){
const row=document.createElement('div');
row.className='msg-row assistant-turn';
row.dataset.role='assistant';
row.innerHTML=`${_assistantRoleHtml(tsTitle)}<div class="assistant-turn-blocks"></div>`;
return row;
}
function _assistantTurnBlocks(turn){
return turn?turn.querySelector('.assistant-turn-blocks'):null;
}
function _thinkingCardHtml(text){
return `<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(text)}</pre></div></div>`;
}
function renderMessages(){
const inner=$('msgInner');
const vis=S.messages.filter(m=>{
if(!m||!m.role||m.role==='tool')return false;
// Keep assistant messages with tool_use content even if they have no text,
// so tool cards can be anchored to their DOM rows on page reload (#140).
if(m.role==='assistant'&&Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use'))return true;
if(m.role==='assistant'){
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;
}
return msgContent(m)||m.attachments?.length;
});
$('emptyState').style.display=vis.length?'none':'';
inner.innerHTML='';
// Track original indices (in S.messages) so truncate knows the cut point.
// Also include assistant messages that have tool_calls (OpenAI format) or
// tool_use content (Anthropic format) even when their text is empty — these
// rows serve as DOM anchors for tool card insertion on page reload.
const visWithIdx=[];
let rawIdx=0;
for(const m of S.messages){
if(!m||!m.role||m.role==='tool'){rawIdx++;continue;}
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu))) visWithIdx.push({m,rawIdx});
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) visWithIdx.push({m,rawIdx});
rawIdx++;
}
let _prevSepKey=null;
let currentAssistantTurn=null;
const assistantSegments=new Map();
for(let vi=0;vi<visWithIdx.length;vi++){
const {m,rawIdx}=visWithIdx[vi];
const _tsSep=m._ts||m.timestamp;
if(_tsSep){
const _d=new Date(_tsSep*1000);
const _key=_d.toDateString();
if(_prevSepKey && _prevSepKey!==_key){
const sep=document.createElement('div');
sep.className='msg-date-sep';
sep.textContent=_fmtDateSep(_d);
inner.appendChild(sep);
}
_prevSepKey=_key;
}
let content=m.content||'';
// Extract thinking/reasoning blocks from structured content (Claude extended thinking, o3)
let thinkingText='';
if(Array.isArray(content)){
thinkingText=content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n');
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
}
// Also check top-level reasoning field (Hermes format)
if(!thinkingText && m.reasoning){
thinkingText=m.reasoning;
}
// Parse inline thinking tags from plain text: <think>...</think> (DeepSeek, QwQ, MiniMax, etc.)
// and Gemma 4 channel tokens: <|channel>thought\n...<channel|>
// Note: no ^ anchor — some models emit leading whitespace/newlines before <think>.
if(!thinkingText && m.reasoning) thinkingText=m.reasoning;
if(!thinkingText && typeof content==='string'){
const thinkMatch=content.match(/<think>([\s\S]*?)<\/think>/);
if(thinkMatch){
@@ -1131,28 +1172,54 @@ function renderMessages(){
}
const isUser=m.role==='user';
const isLastAssistant=!isUser&&vi===visWithIdx.length-1;
// Render thinking card before the assistant message (collapsed by default)
if(thinkingText&&!isUser){
const thinkRow=document.createElement('div');thinkRow.className='msg-row thinking-card-row';
thinkRow.innerHTML=`<div class="thinking-card"><div class="thinking-card-header" onclick="this.parentElement.classList.toggle('open')"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span><span class="thinking-card-toggle">${li('chevron-right',12)}</span></div><div class="thinking-card-body"><pre>${esc(thinkingText)}</pre></div></div>`;
inner.appendChild(thinkRow);
}
const row=document.createElement('div');row.className='msg-row';
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
if(m._live) row.setAttribute('data-live-assistant','1');
let filesHtml='';
if(m.attachments&&m.attachments.length)
if(m.attachments&&m.attachments.length){
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">${li('paperclip',12)} ${esc(f)}</div>`).join('')}</div>`;
}
const bodyHtml = isUser ? esc(String(content)).replace(/\n/g,'<br>') : renderMd(String(content));
// Action buttons for this bubble
const editBtn = isUser ? `<button class="msg-action-btn" title="${t('edit_message')}" onclick="editMessage(this)">${li('pencil',13)}</button>` : '';
const retryBtn = isLastAssistant ? `<button class="msg-action-btn" title="${t('regenerate')}" onclick="regenerateResponse(this)">${li('rotate-ccw',13)}</button>` : '';
const copyBtn = `<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>`;
const tsVal=m._ts||m.timestamp;
const tsTitle=tsVal?new Date(tsVal*1000).toLocaleString():'';
const _bn=window._botName||'Hermes';
row.innerHTML=`<div class="msg-role ${m.role}" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon ${m.role}">${isUser?'Y':esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${isUser?t('you'):esc(_bn)}</span>${tsTitle?`<span class="msg-time">${new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span>`:''}<span class="msg-actions">${editBtn}<button class="msg-copy-btn msg-action-btn" title="${t('copy')}" onclick="copyMsg(this)">${li('copy',13)}</button>${retryBtn}</span></div>${filesHtml}<div class="msg-body">${bodyHtml}</div>`;
row.dataset.rawText = String(content).trim();
inner.appendChild(row);
const tsTime=tsVal?new Date(tsVal*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}):'';
const userTimeHtml = (isUser && tsTime) ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
const footHtml = `<div class="msg-foot">${userTimeHtml}<span class="msg-actions">${editBtn}${copyBtn}${retryBtn}</span></div>`;
if(isUser){
currentAssistantTurn=null;
const row=document.createElement('div');
row.className='msg-row';
row.dataset.msgIdx=rawIdx;
row.dataset.role='user';
row.dataset.rawText=String(content).trim();
row.innerHTML=`${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`;
inner.appendChild(row);
continue;
}
if(!currentAssistantTurn){
currentAssistantTurn=_createAssistantTurn(tsTitle);
inner.appendChild(currentAssistantTurn);
}
const seg=document.createElement('div');
seg.className='assistant-segment';
seg.dataset.msgIdx=rawIdx;
seg.dataset.rawText=String(content).trim();
if(m._live){
currentAssistantTurn.id='liveAssistantTurn';
seg.setAttribute('data-live-assistant','1');
}
if(_ERR_MSG_RE.test(String(content||'').trim())) seg.dataset.error='1';
if(thinkingText) seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText));
const hasVisibleBody=!!(String(content||'').trim()||filesHtml);
if(hasVisibleBody){
seg.insertAdjacentHTML('beforeend', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);
}else if(!thinkingText){
seg.classList.add('assistant-segment-anchor');
}
_assistantTurnBlocks(currentAssistantTurn).appendChild(seg);
assistantSegments.set(rawIdx, seg);
}
// Insert settled tool call cards (history view only).
// During live streaming, tool cards are rendered in #liveToolCards by the
@@ -1163,9 +1230,43 @@ function renderMessages(){
// a display list from per-message tool_calls (OpenAI format) stored in each
// assistant message. This covers the reload case described in issue #140.
if(!S.busy && (!S.toolCalls||!S.toolCalls.length)){
// Pass 1: index tool outputs by tool_call_id / tool_use_id so the
// fallback-built cards carry their result snippet (not just the command).
// Without this step CLI-origin sessions reload with empty tool cards.
const resultsByTid={};
const _snipFromRaw=(raw)=>{
const s=String(raw||'');
try{
const rd=JSON.parse(s);
if(rd && typeof rd==='object') return String(rd.output||rd.result||rd.error||s).slice(0,200);
}catch(e){}
return s.slice(0,200);
};
S.messages.forEach(m=>{
if(!m) return;
// OpenAI / Hermes CLI format: role=tool with tool_call_id
if(m.role==='tool'){
const tid=m.tool_call_id||m.tool_use_id||'';
if(tid) resultsByTid[tid]=_snipFromRaw(m.content);
return;
}
// Anthropic format: tool_result blocks inside a user message content array
if(Array.isArray(m.content)){
m.content.forEach(p=>{
if(!p||typeof p!=='object'||p.type!=='tool_result') return;
const tid=p.tool_use_id||'';
if(!tid) return;
const raw=typeof p.content==='string'?p.content
:Array.isArray(p.content)?p.content.map(c=>c&&c.text?c.text:'').join('')
:'';
resultsByTid[tid]=_snipFromRaw(raw);
});
}
});
const derived=[];
S.messages.forEach((m,rawIdx)=>{
if(m.role!=='assistant') return;
// OpenAI format: top-level tool_calls field on the assistant message
(m.tool_calls||[]).forEach(tc=>{
if(!tc||typeof tc!=='object') return;
const fn=tc.function||{};
@@ -1174,8 +1275,23 @@ function renderMessages(){
try{ args=JSON.parse(fn.arguments||'{}'); }catch(e){}
let argsSnap={};
Object.keys(args).slice(0,4).forEach(k=>{ const v=String(args[k]); argsSnap[k]=v.slice(0,120)+(v.length>120?'...':''); });
derived.push({name,snippet:'',tid:tc.id||tc.call_id||'',assistant_msg_idx:rawIdx,args:argsSnap,done:true});
const tid=tc.id||tc.call_id||'';
derived.push({name,snippet:resultsByTid[tid]||'',tid,assistant_msg_idx:rawIdx,args:argsSnap,done:true});
});
// Anthropic format: tool_use blocks inside assistant content array
if(Array.isArray(m.content)){
m.content.forEach(p=>{
if(!p||typeof p!=='object'||p.type!=='tool_use') return;
const name=p.name||'tool';
const args=p.input||{};
const argsSnap={};
if(args && typeof args==='object'){
Object.keys(args).slice(0,4).forEach(k=>{ const v=String(args[k]); argsSnap[k]=v.slice(0,120)+(v.length>120?'...':''); });
}
const tid=p.id||'';
derived.push({name,snippet:resultsByTid[tid]||'',tid,assistant_msg_idx:rawIdx,args:argsSnap,done:true});
});
}
});
if(derived.length) S.toolCalls=derived;
}
@@ -1187,40 +1303,24 @@ function renderMessages(){
if(!byAssistant[key]) byAssistant[key] = [];
byAssistant[key].push(tc);
}
const allRows = Array.from(inner.querySelectorAll('.msg-row[data-msg-idx]'));
// Track the last inserted node per anchor so back-to-back groups for the
// same (filtered) anchor row are inserted in chronological order.
const assistantIdxs=[...assistantSegments.keys()].sort((a,b)=>a-b);
const anchorInsertAfter = new Map();
for(const [key, cards] of Object.entries(byAssistant)){
const aIdx = parseInt(key);
// Find the right insertion point: cards go AFTER the assistant message
// that triggered them. We look for the row at aIdx, or the nearest
// visible ASSISTANT row at or before aIdx (the assistant message may be
// filtered out if it contained only tool_use blocks with no text response).
let anchorRow = null;
if(aIdx >= 0){
// First: exact match for the assistant row
for(const r of allRows){
const ri=parseInt(r.dataset.msgIdx||'-1');
if(ri===aIdx){anchorRow=r;break;}
}
// Fallback: nearest visible ASSISTANT row at or before aIdx
if(!anchorRow){
for(let i=allRows.length-1;i>=0;i--){
const ri=parseInt(allRows[i].dataset.msgIdx||'-1');
if(ri<=aIdx&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
}
}
}
// aIdx === -1 or no assistant anchor found: attach after the last assistant row
if(!anchorRow){
for(let i=allRows.length-1;i>=0;i--){
const ri=parseInt(allRows[i].dataset.msgIdx||'-1',10);
if(ri>=0&&S.messages[ri]&&S.messages[ri].role==='assistant'){anchorRow=allRows[i];break;}
}
let anchorRow=assistantSegments.get(aIdx)||null;
if(!anchorRow&&assistantIdxs.length){
const fallbackIdx=[...assistantIdxs].reverse().find(idx=>idx<=aIdx);
anchorRow=fallbackIdx!==undefined?assistantSegments.get(fallbackIdx):assistantSegments.get(assistantIdxs[assistantIdxs.length-1]);
}
if(!anchorRow) continue;
const anchorParent=anchorRow.parentElement;
const frag=document.createDocumentFragment();
for(const tc of cards){frag.appendChild(buildToolCard(tc));}
let lastInsertedNode=null;
for(const tc of cards){
const card=buildToolCard(tc);
frag.appendChild(card);
lastInsertedNode=card;
}
// Add expand/collapse toggle for groups with 2+ cards
if(cards.length>=2){
const toggle=document.createElement('div');
@@ -1237,22 +1337,18 @@ function renderMessages(){
toggle.appendChild(collapseBtn);
frag.insertBefore(toggle,frag.firstChild);
}
// Insert after the anchor row (or after any previously inserted group for
// the same anchor), preserving chronological order for multi-step chains.
const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
const refNode = insertAfterNode ? insertAfterNode.nextSibling : null;
if(refNode) inner.insertBefore(frag,refNode);
else inner.appendChild(frag);
// Record the last child we inserted so the next group for this anchor
// goes after it rather than back at anchorRow.nextSibling.
anchorInsertAfter.set(anchorRow, inner.lastChild);
if(refNode) anchorParent.insertBefore(frag,refNode);
else anchorParent.appendChild(frag);
if(anchorRow&&lastInsertedNode) anchorInsertAfter.set(anchorRow, lastInsertedNode);
}
}
// Render usage badge on the last assistant message row (if enabled and usage data exists)
// Render usage badge on the last assistant turn (if enabled and usage data exists)
if(window._showTokenUsage&&S.session&&(S.session.input_tokens||S.session.output_tokens)){
const rows=inner.querySelectorAll('.msg-row');
const rows=inner.querySelectorAll('.assistant-turn');
let lastAssist=null;
for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}}
for(let i=rows.length-1;i>=0;i--){lastAssist=rows[i];break;}
if(lastAssist&&!lastAssist.querySelector('.msg-usage')){
const usage=document.createElement('div');
usage.className='msg-usage';
@@ -1262,7 +1358,7 @@ function renderMessages(){
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
usage.textContent=text;
lastAssist.appendChild(usage);
_assistantTurnBlocks(lastAssist).appendChild(usage);
}
}
scrollToBottom();
@@ -1299,7 +1395,7 @@ function toolIcon(name){
function buildToolCard(tc){
const row=document.createElement('div');
row.className='msg-row tool-card-row';
row.className='tool-card-row';
const icon=toolIcon(tc.name);
const hasDetail=tc.snippet||(tc.args&&Object.keys(tc.args).length>0);
let displaySnippet='';
@@ -1330,7 +1426,7 @@ function buildToolCard(tc){
<span class="tool-card-icon">${icon}</span>
<span class="tool-card-name">${esc(displayName)}</span>
<span class="tool-card-preview">${esc(previewText)}</span>
${hasDetail?'<span class="tool-card-toggle"></span>':''}
${hasDetail?`<span class="tool-card-toggle">${li('chevron-right',12)}</span>`:''}
</div>
${hasDetail?`<div class="tool-card-detail">
${tc.args&&Object.keys(tc.args).length?`<div class="tool-card-args">${
@@ -1346,30 +1442,55 @@ function buildToolCard(tc){
}
// ── Live tool card helpers (called during SSE streaming) ──
// Live cards are inserted INLINE inside #msgInner (tagged with data-live-tid)
// so the streaming layout matches the settled layout produced by renderMessages
// (user → thinking → tool cards → response). The legacy #liveToolCards
// sibling container is no longer used for placement — keeping the cards in the
// message column eliminates the visible "jump" users saw when renderMessages
// fired on the done event.
function appendLiveToolCard(tc){
const container=$('liveToolCards');
if(!container)return;
container.style.display='';
// Update existing card if same tool call id (e.g. snippet arrives after done)
const existing=container.querySelector(`[data-tid="${CSS.escape(tc.tid||'')}"]`);
if(existing){existing.replaceWith(buildToolCard(tc));return;}
const card=buildToolCard(tc);
if(tc.tid)card.dataset.tid=tc.tid;
container.appendChild(card);
let turn=$('liveAssistantTurn');
if(!turn){
appendThinking();
turn=$('liveAssistantTurn');
}
const inner=_assistantTurnBlocks(turn);
if(!inner) return;
const tid=tc.tid||'';
// Update existing card in place (tool_complete after tool_start)
if(tid){
const existing=inner.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
if(existing){
const replacement=buildToolCard(tc);
replacement.dataset.liveTid=tid;
existing.replaceWith(replacement);
return;
}
}
const row=buildToolCard(tc);
if(tid) row.dataset.liveTid=tid;
// Insert BEFORE the live assistant segment if it exists, so tool cards stay
// between the current thinking block(s) and the streaming response.
const liveAssistant=inner.querySelector('[data-live-assistant="1"]');
if(liveAssistant) inner.insertBefore(row, liveAssistant);
else inner.appendChild(row);
if(typeof scrollIfPinned==='function') scrollIfPinned();
}
function clearLiveToolCards(){
const inner=_assistantTurnBlocks($('liveAssistantTurn'));
if(inner) inner.querySelectorAll('.tool-card-row[data-live-tid]').forEach(el=>el.remove());
// Legacy #liveToolCards container cleanup — kept for safety in case any
// leftover cards were inserted there before this refactor took effect.
const container=$('liveToolCards');
if(!container)return;
container.innerHTML='';
container.style.display='none';
if(container){container.innerHTML='';container.style.display='none';}
}
// ── Edit + Regenerate ──
function editMessage(btn) {
if(S.busy) return;
const row = btn.closest('.msg-row');
const row = btn.closest('[data-msg-idx]');
if(!row) return;
const msgIdx = parseInt(row.dataset.msgIdx, 10);
const originalText = row.dataset.rawText || '';
@@ -1439,7 +1560,7 @@ async function regenerateResponse(btn) {
if(!S.session || S.busy) return;
// Find the last user message and re-run it
// Remove the last assistant message first (truncate to before it)
const row = btn.closest('.msg-row');
const row = btn.closest('[data-msg-idx]');
if(!row) return;
const assistantIdx = parseInt(row.dataset.msgIdx, 10);
// Find the last user message text (one before this assistant message)
@@ -1583,29 +1704,45 @@ function renderKatexBlocks(){
}
function _thinkingMarkup(text=''){
const _bn=window._botName||'Hermes';
const icon=esc(_bn.charAt(0).toUpperCase());
const label=esc(_bn);
const body=(text&&String(text).trim())
return (text&&String(text).trim())
? `<div class="thinking-card open"><div class="thinking-card-header"><span class="thinking-card-icon">${li('lightbulb',14)}</span><span class="thinking-card-label">${t('thinking')}</span></div><div class="thinking-card-body"><pre>${esc(String(text).trim())}</pre></div></div>`
: `<div class="thinking"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>`;
return `<div class="msg-role assistant"><div class="role-icon assistant">${icon}</div>${label}</div>${body}`;
}
function finalizeThinkingCard(){
const row=$('thinkingRow');
if(!row) return;
row.removeAttribute('id');
row.removeAttribute('data-thinking-active');
}
function appendThinking(text=''){
$('emptyState').style.display='none';
let turn=$('liveAssistantTurn');
if(!turn){
turn=_createAssistantTurn();
turn.id='liveAssistantTurn';
$('msgInner').appendChild(turn);
}
const blocks=_assistantTurnBlocks(turn);
let row=$('thinkingRow');
if(!row){
row=document.createElement('div');
row.className='msg-row';
row.className='assistant-segment';
row.id='thinkingRow';
$('msgInner').appendChild(row);
row.setAttribute('data-thinking-active','1');
blocks.appendChild(row);
}
row.className=(text&&String(text).trim())?'msg-row thinking-card-row':'msg-row';
row.className=(text&&String(text).trim())?'assistant-segment thinking-card-row':'assistant-segment';
row.innerHTML=_thinkingMarkup(text);
scrollToBottom();
}
function updateThinking(text=''){appendThinking(text);}
function removeThinking(){const el=$('thinkingRow');if(el)el.remove();}
function removeThinking(){
const el=$('thinkingRow');
if(el) el.remove();
const turn=$('liveAssistantTurn');
const blocks=_assistantTurnBlocks(turn);
if(turn&&blocks&&!blocks.children.length) turn.remove();
}
function fileIcon(name, type){
if(type==='dir') return li('folder',14);