Merge pull request #3147 from nesquena/release/stage-batch43

Release v0.51.161 — Release EG (stage-batch43: 3-PR live-display fixes)
This commit is contained in:
nesquena-hermes 2026-05-29 17:50:01 -07:00 committed by GitHub
commit cbc74e33b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 116 additions and 13 deletions

View file

@ -3,6 +3,16 @@
## [Unreleased]
## [v0.51.161] — 2026-05-29 — Release EG (stage-batch43 — 3-PR live-display fixes: jump-to-question on intermediate assistant messages + per-turn usage badge persistence + stale unread/compression-timer/tool-card dedup)
### Fixed
- The jump-to-question button now appears on every assistant message that has a resolvable question target, not only the turn-final one. Multi-step turns (tool call → assistant → tool call → assistant) previously stripped the navigation affordance from intermediate assistant bubbles.
- Per-turn ephemeral fields (`_turnUsage`, `_turnDuration`, `_turnTps`, `_gatewayRouting`) are now carried forward when a session refresh replaces the in-memory message list with fresh server data, so the usage badge / duration / gateway-routing pill no longer flash and disappear after a compaction restore, external active-session poll, or SSE error recovery (#3018).
- The sidebar unread dot no longer sticks on a session after it has been viewed: syncing the viewed count now clears any stale completion-unread marker, and an actively-viewed session syncs its count instead of being flagged unread on tab switch (#3020).
- The auto-compression card's elapsed timer is now cleared on completion/error, so a replaced card is no longer treated as a still-running compression; a background-session completion no longer kills the active session's compression timer (#2973).
- Tool cards no longer duplicate the result text in both the header and the detail row: the completed tool result is routed to the detail snippet (falling back to the header only when no progress text was streamed), and the detail row is suppressed when the snippet equals the header preview.
## [v0.51.160] — 2026-05-29 — Release EF (stage-batch42 — 3-PR low-risk cleanup: OpenCode shared-key detection + skills-panel profile-aware disabled read + session-index metadata refresh perf)
### Fixed

View file

@ -1673,7 +1673,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
tc={name:d.name||'tool', preview:d.preview||'', args:d.args||{}, snippet:'', done:true};
inflight.toolCalls.push(tc);
}
tc.preview=d.preview||tc.preview||'';
// Route result to .snippet (detail) instead of overwriting .preview
// (header). During streaming .preview already holds the last progress
// text — replacing it with the same content caused header/detail
// duplication. Fallback: if no progress events were sent, use the
// result as preview so the header is not blank.
if(d.preview){
tc.snippet=tc.snippet||d.preview;
if(!tc.preview) tc.preview=d.preview;
}
tc.args=d.args||tc.args||{};
tc.done=true;
tc.is_error=!!d.is_error;
@ -1845,7 +1853,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const _prevCost=(S.session&&S.session.estimated_cost)||0;
const _prevCacheRead=(S.session&&S.session.cache_read_tokens)||0;
const _prevCacheWrite=(S.session&&S.session.cache_write_tokens)||0;
S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated;
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);
@ -2244,7 +2252,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
if(data&&data.session&&S.session&&S.session.session_id===activeSid){
S.session=data.session;
S.messages=(data.session.messages||[]).filter(m=>m&&m.role);
const _nextMsgs3018=(data.session.messages||[]).filter(m=>m&&m.role);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
clearLiveToolCards();if(!assistantText)removeThinking();
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
renderMessages({preserveScroll:true});
@ -2268,6 +2277,48 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
}
// #3018: per-turn ephemeral fields are computed client-side in _finishDone
// and attached to message objects (S.messages). When a server refresh
// (loadSession, _restoreSettledSession, external active-session poll,
// SSE error recovery) replaces S.messages with fresh server data, those
// fields are dropped and the usage badge / duration / gateway routing
// pill flashes-then-disappears. Carry them forward by matching messages
// on (role, timestamp, content prefix) — the same identity the renderer
// already uses for stable keys.
function _messageIdentityKey(m){
if(!m||!m.role) return '';
const ts=m._ts||m.timestamp||'';
let body='';
if(typeof m.content==='string') body=m.content;
else if(Array.isArray(m.content)){
try{ body=m.content.map(p=>(p&&typeof p==='object')?(p.text||p.input_text||'')||'':String(p||'')).join('').slice(0,160); }catch(_){ body=''; }
}
return `${m.role}|${ts}|${body.slice(0,160)}`;
}
const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard'];
function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){
if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages;
if(!prevMessages.length||!nextMessages.length) return nextMessages;
const prevIdx=new Map();
for(const pm of prevMessages){
const k=_messageIdentityKey(pm); if(!k) continue;
// If duplicate keys, prefer the latest occurrence (it carries the
// most-recently-attached ephemeral state).
prevIdx.set(k,pm);
}
for(const nm of nextMessages){
const k=_messageIdentityKey(nm); if(!k) continue;
const pm=prevIdx.get(k); if(!pm) continue;
for(const f of _EPHEMERAL_TURN_FIELDS){
if(pm[f]!=null && nm[f]==null) nm[f]=pm[f];
}
}
return nextMessages;
}
if(typeof window!=='undefined'){
window._carryForwardEphemeralTurnFields=_carryForwardEphemeralTurnFields;
}
async function _restoreSettledSession(source){
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
@ -2296,7 +2347,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(isActiveSession){
S.activeStreamId=null;
clearLiveToolCards();if(!assistantText)removeThinking();
S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);
S.session=session;
const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);
S.messages=_carryForwardEphemeralTurnFields(S.messages||[], _nextMsgs3018);
if(S.session&&S.session.session_id){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id);

View file

@ -142,6 +142,9 @@ function _setSessionViewedCount(sid, messageCount = 0) {
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
counts[sid] = next;
_saveSessionViewedCounts();
// If the viewed count is now current, any prior completion-unread marker is
// stale — clear it so _hasUnreadForSession doesn't short-circuit (#3020).
_clearSessionCompletionUnread(sid);
}
function _getSessionCompletionUnread() {
@ -407,8 +410,13 @@ function _markPollingCompletionUnreadTransitions(sessions) {
)
);
const completedPersistedObservedStream = Boolean(observedStreaming && !isStreaming);
if ((completedObservedStream || completedPersistedObservedStream || completedWithNewMessages) && !_isSessionActivelyViewedForList(sid)) {
_markSessionCompletionUnread(sid, s.message_count);
if (completedObservedStream || completedPersistedObservedStream || completedWithNewMessages) {
if (!_isSessionActivelyViewedForList(sid)) {
_markSessionCompletionUnread(sid, s.message_count);
} else {
// Sync viewed count so we don't flag stale unread on tab switch (#3020)
_setSessionViewedCount(sid, messageCount);
}
}
_sessionStreamingById.set(sid, isStreaming);
if (isStreaming) {
@ -1319,6 +1327,11 @@ async function _ensureMessagesLoaded(sid) {
S.toolCalls = [];
}
clearLiveToolCards();
// #3018: preserve client-side ephemeral turn fields (_turnUsage, _turnDuration,
// _turnTps, _gatewayRouting, _statusCard) across the loadSession replace.
if(typeof window._carryForwardEphemeralTurnFields==='function'){
msgs=window._carryForwardEphemeralTurnFields(S.messages||[], msgs);
}
S.messages = msgs;
if(S.session&&S.session.session_id===sid){
S.session.message_count=Number(data.session.message_count || msgs.length);

View file

@ -5682,6 +5682,19 @@ function appendLiveCompressionCard(state){
node.setAttribute('data-compression-started-at',String(started));
node.setAttribute('data-compression-message',String(state.message||'Auto-compressing context...'));
_startCompressionElapsedTimer();
} else {
// Completion or error: clear the elapsed-timer attributes so the
// interval reader (_compressionLiveCardState) doesn't keep treating
// the replaced card as a running compression (#2973).
node.removeAttribute('data-compression-started-at');
node.removeAttribute('data-compression-message');
// Only clear the global timer when the *active* session has no running
// compression. An SSE completion for a background session must not
// kill the timer that's driving the current session's display.
const _activeCompState = _compressionStateForCurrentSession();
if (!_activeCompState || !_activeCompState.automatic || _activeCompState.phase !== 'running') {
_clearCompressionElapsedTimer();
}
}
const existing=inner.querySelector('[data-live-compression-card="1"]');
if(existing) existing.replaceWith(node);
@ -6494,8 +6507,14 @@ function renderMessages(options){
const tsTitle=tsVal?(_fmtSv?_fmtSv(new Date(tsVal*1000),{}):new Date(tsVal*1000).toLocaleString()):'';
const tsTime=_formatMessageFooterTimestamp(tsVal);
const timeHtml = tsTime ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
const questionJumpBtn = (!isUser&&!m._live&&isTurnFinalAssistant)
? _questionJumpButtonHtml(questionRawIdxByAssistantRawIdx.get(rawIdx))
// #3114: show jump-to-question on every assistant message that has a
// resolvable question target, not just the turn-final one. Multi-step
// turns (tool_call -> assistant -> tool_call -> assistant) otherwise
// strip the button from every intermediate assistant bubble and the
// user loses the navigation affordance.
const _qJumpTarget=(!isUser&&!m._live)?questionRawIdxByAssistantRawIdx.get(rawIdx):undefined;
const questionJumpBtn = (_qJumpTarget!==undefined&&_qJumpTarget!==null)
? _questionJumpButtonHtml(_qJumpTarget)
: '';
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${ttsBtn}${forkBtn}${copyBtn}${retryBtn}</span>${questionJumpBtn}</div>`;
@ -6941,7 +6960,7 @@ function buildToolCard(tc){
const row=document.createElement('div');
row.className='tool-card-row';
const icon=toolIcon(tc.name);
const hasDetail=tc.snippet||(tc.args&&Object.keys(tc.args).length>0);
const hasDetail=(tc.snippet&&tc.snippet!==tc.preview)||(tc.args&&Object.keys(tc.args).length>0);
let displaySnippet='';
if(tc.snippet){
const s=tc.snippet;

View file

@ -15,8 +15,14 @@ def test_assistant_footer_gets_completed_turn_question_jump_button():
assert "questionRawIdxByAssistantRawIdx.set(entry.rawIdx,lastQuestionRawIdx)" in UI_JS
assert "row.id=_userMessageDomId(rawIdx)" in UI_JS
assert "const isTurnFinalAssistant=!isUser&&(!nextRendered||!nextRendered.m||nextRendered.m.role!=='assistant')" in UI_JS
assert "(!isUser&&!m._live&&isTurnFinalAssistant)" in UI_JS
assert "_questionJumpButtonHtml(questionRawIdxByAssistantRawIdx.get(rawIdx))" in UI_JS
# #3114 superseded the turn-final-only gate: the jump-to-question button now
# renders on every assistant message that has a resolvable question target,
# not just the turn-final one (multi-step turns otherwise lost the affordance
# on intermediate assistant bubbles). The button is gated on a non-null
# resolved target instead of isTurnFinalAssistant.
assert "const _qJumpTarget=(!isUser&&!m._live)?questionRawIdxByAssistantRawIdx.get(rawIdx):undefined;" in UI_JS
assert "const questionJumpBtn = (_qJumpTarget!==undefined&&_qJumpTarget!==null)" in UI_JS
assert "_questionJumpButtonHtml(_qJumpTarget)" in UI_JS
assert "msg-question-jump-btn" in UI_JS

View file

@ -7,8 +7,10 @@ MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
def test_stream_completion_syncs_rotated_session_id_to_tab_state():
"""When compact/restore returns a new session id, the tab anchor follows it."""
completion_marker = "S.session=d.session;S.messages=d.session.messages||[]"
settled_marker = "S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role);"
# #3018 inserted a carry-forward of ephemeral per-turn fields into both the
# completion (_finishDone) and settled-restore assignments; match the new shapes.
completion_marker = "S.session=d.session;S.messages=_carryForwardEphemeralTurnFields(S.messages||[], d.session.messages||[]);"
settled_marker = "S.session=session;\n const _nextMsgs3018=(session.messages||[]).filter(m=>m&&m.role);"
completion_pos = MESSAGES_JS.find(completion_marker)
settled_pos = MESSAGES_JS.find(settled_marker)