From 24b175045f0d8098111176c0dc2f7509b30f801a Mon Sep 17 00:00:00 2001
From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com>
Date: Fri, 29 May 2026 03:24:48 -0700
Subject: [PATCH 1/9] Fix #3114: keep jump-to-question button on intermediate
assistant messages
In multi-step turns (assistant -> tool_call -> assistant -> tool_call ->
final assistant), only the turn-final assistant bubble was rendering the
'jump to question' navigation button because the gate keyed on
isTurnFinalAssistant. Intermediate assistant bubbles that *do* have a
resolvable question raw-index lost the affordance entirely.
Switch the gate to 'show whenever questionRawIdxByAssistantRawIdx has a
target for this rawIdx', which is the actual precondition for the button
being meaningful. Turn-finality was a proxy for 'has a question target'
that under-covered multi-step turns.
No template/CSS change needed; _questionJumpButtonHtml already handles
the rawIdx-or-undefined contract.
---
static/ui.js | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/static/ui.js b/static/ui.js
index d209e151..66004f9b 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -6494,8 +6494,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 ? `${tsTime}` : '';
- 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 = `
`;
From d45bc32c8dfa6862fddcb8cff69d2c4b74153e01 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sat, 30 May 2026 00:10:29 +0000
Subject: [PATCH 2/9] test: update #2246 question-jump assertions for #3114
(button on all assistant msgs with resolvable target)
---
tests/test_issue2246_question_jump.py | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/tests/test_issue2246_question_jump.py b/tests/test_issue2246_question_jump.py
index e06ce874..c4f81460 100644
--- a/tests/test_issue2246_question_jump.py
+++ b/tests/test_issue2246_question_jump.py
@@ -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
From 01c27806d4d96d3e9413384307f2ff9a75ed05f6 Mon Sep 17 00:00:00 2001
From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com>
Date: Fri, 29 May 2026 03:27:39 -0700
Subject: [PATCH 3/9] Fix #3018: preserve per-turn ephemeral fields across
session refreshes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`_turnUsage` (and `_turnDuration`, `_turnTps`, `_gatewayRouting`,
`_statusCard`) are computed client-side in `_finishDone()` and attached
to the last assistant message in `S.messages`. Three code paths replace
`S.messages` wholesale with fresh API data that lacks these fields:
1. `_restoreSettledSession()` after a late `stream_end` or SSE error.
2. The late-restore branch after `done` (messages.js ~L2247).
3. `loadSession()` for active-session external refresh / focus-change.
When any of these fire after `done`, the badge/duration/gateway-pill
flashes for ~1s and disappears, never returning until the next message
or page reload.
Add `_carryForwardEphemeralTurnFields(prev, next)` that matches messages
by `(role, timestamp, content prefix)` — the same identity the renderer
already uses — and copies forward the ephemeral fields when the server
payload is missing them. Wire it into all three replace sites. The fix
is conservative: it only fills slots that are `null` on the new message,
so an authoritative server-side value (if/when the API ever surfaces
per-turn usage) wins automatically.
Picked Option A from the bug report (preserve on the client side) over
Option B (synthesize from `S.lastUsage`) because `lastUsage` is a
session-level aggregate; reconstructing per-turn breakdowns from it is
lossy. Option C (set `_streamFinalized` earlier) would suppress legit
late-arriving server data on transient errors.
---
static/messages.js | 49 ++++++++++++++++++++++++++++++++++++++++++++--
static/sessions.js | 5 +++++
2 files changed, 52 insertions(+), 2 deletions(-)
diff --git a/static/messages.js b/static/messages.js
index 05dd8d2a..3226bac3 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -2244,7 +2244,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 +2269,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 +2339,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);
diff --git a/static/sessions.js b/static/sessions.js
index fc90f1f5..22166f55 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -1319,6 +1319,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);
From 6d56280e94324d7560b401162e66b13222dc72e2 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sat, 30 May 2026 00:16:32 +0000
Subject: [PATCH 4/9] test: update session-rotate-url-sync marker for #3018
split assignment (behavior preserved)
---
tests/test_session_rotate_url_sync.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/tests/test_session_rotate_url_sync.py b/tests/test_session_rotate_url_sync.py
index a941a3d7..47037d34 100644
--- a/tests/test_session_rotate_url_sync.py
+++ b/tests/test_session_rotate_url_sync.py
@@ -8,7 +8,9 @@ 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 split the single-line settled assignment to insert the
+ # carry-forward of ephemeral per-turn fields; match the new shape.
+ 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)
From 0002cda23ddd42f2af6fc31fa800370e11444790 Mon Sep 17 00:00:00 2001
From: mysoul12138 <839465496@qq.com>
Date: Fri, 29 May 2026 12:23:15 +0800
Subject: [PATCH 5/9] fix: stale unread dot, compression timer leak, tool card
duplication (#3020, #2973)
#3020: Sync viewed-count in the polling path for actively-viewed sessions
so navigating away doesn't show a stale unread dot. Defensive clear of
completion-unread marker in _setSessionViewedCount.
#2973: Clear elapsed-timer attributes and interval when a live compression
card transitions from running to done, preventing the orphan timer from
overwriting the completed card state. Guarded by active-session check.
Tool card duplication: Route tool_complete result to tc.snippet (detail)
instead of tc.preview (header) to prevent identical content appearing in
both the card header and expanded detail section.
Co-Authored-By: Claude Opus 4.7
---
static/messages.js | 10 +++++++++-
static/sessions.js | 12 ++++++++++--
static/ui.js | 13 +++++++++++++
3 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/static/messages.js b/static/messages.js
index 05dd8d2a..085f402c 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -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;
diff --git a/static/sessions.js b/static/sessions.js
index fc90f1f5..4a89c0d6 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -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) {
diff --git a/static/ui.js b/static/ui.js
index d209e151..2afe41ab 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -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);
From 5b3771012af70dd097c896aeb062c80f40d2b238 Mon Sep 17 00:00:00 2001
From: mysoul12138 <839465496@qq.com>
Date: Fri, 29 May 2026 16:01:18 +0800
Subject: [PATCH 6/9] Fix tool card duplication: skip detail when snippet
equals preview
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When tc.snippet === tc.preview (common for no-progress tools where
both are set to the same result_snippet), the detail block would show
identical content as the header. Skip the detail block in this case.
This also handles the reload-path where derived entries get snippet
populated but no preview, so displaySnippet falls back to the snippet
content for the header — same deduplication applies.
Co-Authored-By: Claude Opus 4.7
---
static/ui.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/ui.js b/static/ui.js
index 2afe41ab..25bf4a36 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -6954,7 +6954,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;
From 49a16e5fc3201013234ac059538a84822ab43fce Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sat, 30 May 2026 00:21:35 +0000
Subject: [PATCH 7/9] stage-batch43: CHANGELOG for v0.51.161 (Release EG)
---
CHANGELOG.md | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22fdcfdc..4459791a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
From 7edd43549de4d369cbd299d3747f100bf6628e07 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sat, 30 May 2026 00:39:54 +0000
Subject: [PATCH 8/9] =?UTF-8?q?stage-batch43:=20extend=20#3018=20carry-for?=
=?UTF-8?q?ward=20to=20=5FfinishDone=20completion=20path=20(Opus=20review?=
=?UTF-8?q?=20=E2=80=94=20most-frequent=20reproducer)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
static/messages.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/static/messages.js b/static/messages.js
index 8aa30284..1c92a2ae 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -1853,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);
From 3119ea82001f1e5e1664fe976b8fad134818e152 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Sat, 30 May 2026 00:43:37 +0000
Subject: [PATCH 9/9] test: update rotate-url-sync completion_marker for #3018
_finishDone carry-forward
---
tests/test_session_rotate_url_sync.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/test_session_rotate_url_sync.py b/tests/test_session_rotate_url_sync.py
index 47037d34..17d7a810 100644
--- a/tests/test_session_rotate_url_sync.py
+++ b/tests/test_session_rotate_url_sync.py
@@ -7,9 +7,9 @@ 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||[]"
- # #3018 split the single-line settled assignment to insert the
- # carry-forward of ephemeral per-turn fields; match the new shape.
+ # #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)