fix(ui): hover-only footer chrome with timestamps for both user and assistant — v0.50.110 (fixes #680) (#758)
Squash merge of PR #717 — rebased on behalf of @franksong2702. ## What it does Fixes #680. Footer chrome (timestamps, copy, edit, regenerate) is now hover-only for both user and assistant message rows, consistent throughout the conversation. The last assistant turn keeps cumulative usage visible at rest; timestamp and actions are revealed inline on hover in the same row. Key changes: - `static/ui.js`: new `_formatMessageFooterTimestamp()` (local timezone, cross-day fuller format); `timeHtml` no longer gated to user-only; last assistant usage moved from separate `.msg-usage` div to inline `.msg-usage-inline` span in the footer - `static/style.css`: `.msg-foot-with-usage` class + rules; assistant footer opacity changed from 0.45 to 0 (hover-only); `:focus-within` alongside `:hover` for keyboard users - `api/streaming.py`: `_restore_reasoning_metadata()` now preserves `_ts`/`timestamp` for unchanged historical messages - `tests/test_sprint49.py`: 8 new tests covering rendering contract, hover CSS, timestamp preservation Tests: 1518 passed. QA: 20/20. Browser verified. Reviewed and approved by @nesquena and @aronprins.
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,10 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.110] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
- **Message footer metadata is now consistent across user and assistant turns** — timestamps are available on both sides, but footer chrome stays hidden until hover instead of being always visible on assistant messages. The last assistant turn keeps cumulative `in/out/cost` usage visible, then reveals timestamp and actions inline on hover. Existing timestamps for unchanged historical messages are also preserved during transcript rebuilds, so older turns no longer get re-stamped to the newest reply time. (Fixes #680, credit: @franksong2702)
|
||||
|
||||
## [v0.50.109] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
@@ -67,6 +72,7 @@
|
||||
|
||||
### Fixed
|
||||
- **Only the latest user message can be edited** — older user turns no longer show the pencil/edit affordance. This avoids implying that historical turns can be lightly edited when the actual action truncates the session and restarts the conversation from that point. (Closes #744)
|
||||
- **Message footer metadata is now consistent across user and assistant turns** — timestamps are available on both sides using the existing `_ts` / `timestamp` fields, but footer chrome now stays hidden until hover instead of being always visible on assistant messages. The last assistant turn keeps cumulative `in/out/cost` usage visible, then reveals timestamp and actions inline on hover so the footer does not grow an extra row. Existing timestamps for unchanged historical messages are also preserved during transcript rebuilds, so older turns no longer get re-stamped to the newest reply time.
|
||||
|
||||
## [v0.50.96] — 2026-04-19
|
||||
|
||||
@@ -91,6 +97,10 @@
|
||||
- **Workspace file panel shows an empty-state message** instead of a blank pane when no workspace is configured or the directory is empty. (#703)
|
||||
- **Notification settings description uses "app" instead of "tab"** — more accurate for native Mac app users. (#704)
|
||||
(PR #712)
|
||||
## [v0.50.95] — 2026-04-19
|
||||
|
||||
### Fixed
|
||||
- **Assistant messages now show footer timestamps, and older messages show a fuller date+time** — assistant response segments now render the same footer timestamp affordance as user messages, using the existing message `_ts` / `timestamp` fields already stamped by the WebUI. Messages from today still show a compact time-only label, while older messages now show a fuller date+time string directly in the footer for better readability when reviewing past sessions.
|
||||
|
||||
## [v0.50.94] — 2026-04-19
|
||||
|
||||
|
||||
@@ -619,11 +619,16 @@ def _api_safe_message_positions(messages):
|
||||
|
||||
|
||||
def _restore_reasoning_metadata(previous_messages, updated_messages):
|
||||
"""Carry forward assistant reasoning metadata lost during API-safe history sanitization.
|
||||
"""Carry forward display-only metadata lost during API-safe history sanitization.
|
||||
|
||||
The provider-facing history strips WebUI-only fields like `reasoning`. When the
|
||||
agent returns its new full message history, prior assistant messages come back
|
||||
without that metadata unless we merge it back in by API-history position.
|
||||
|
||||
This also preserves existing timestamps for unchanged historical messages.
|
||||
Without that, older turns that come back from the agent without `_ts` /
|
||||
`timestamp` can be re-stamped with the current time on every new assistant
|
||||
response, making prior messages appear to "move" in time.
|
||||
"""
|
||||
if not previous_messages or not updated_messages:
|
||||
return updated_messages
|
||||
@@ -651,6 +656,10 @@ def _restore_reasoning_metadata(previous_messages, updated_messages):
|
||||
if isinstance(prev_msg, dict) and isinstance(cur_msg, dict) and _safe_projection(prev_msg) == _safe_projection(cur_msg):
|
||||
if prev_msg.get('role') == 'assistant' and prev_msg.get('reasoning') and not cur_msg.get('reasoning'):
|
||||
cur_msg['reasoning'] = prev_msg['reasoning']
|
||||
if prev_msg.get('timestamp') and not cur_msg.get('timestamp'):
|
||||
cur_msg['timestamp'] = prev_msg['timestamp']
|
||||
elif prev_msg.get('_ts') and not cur_msg.get('_ts') and not cur_msg.get('timestamp'):
|
||||
cur_msg['_ts'] = prev_msg['_ts']
|
||||
safe_pos += 1
|
||||
continue
|
||||
|
||||
|
||||
@@ -1642,6 +1642,27 @@ body.bubble-layout .msg-row + .msg-row[data-role="user"] { border-top: none; pad
|
||||
}
|
||||
.msg-foot .msg-actions { opacity: 1; margin-left: 0; }
|
||||
.msg-foot .msg-time { font-size: 10.5px; opacity: .75; }
|
||||
.msg-foot-with-usage {
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.msg-usage-inline {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
opacity: .7;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.msg-foot-with-usage .msg-time,
|
||||
.msg-foot-with-usage .msg-actions {
|
||||
opacity: 0;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.msg-foot-with-usage .msg-time {
|
||||
margin-left: 4px;
|
||||
}
|
||||
.msg-foot-with-usage .msg-actions {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* User footer: visible only on row hover (bubble identifies sender without needing persistent chrome) */
|
||||
.msg-row[data-role="user"] .msg-foot {
|
||||
@@ -1658,11 +1679,24 @@ body.bubble-layout .msg-row + .msg-row[data-role="user"] { border-top: none; pad
|
||||
justify-content: flex-start;
|
||||
padding-left: var(--msg-rail);
|
||||
max-width: var(--msg-max);
|
||||
opacity: .45;
|
||||
opacity: 0;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.msg-row[data-role="assistant"]:hover .msg-foot,
|
||||
.assistant-turn:hover .msg-foot { opacity: 1; }
|
||||
.assistant-turn:hover .msg-foot,
|
||||
.assistant-turn:focus-within .msg-foot { opacity: 1; }
|
||||
.assistant-turn .msg-foot-with-usage,
|
||||
.msg-row[data-role="assistant"] .msg-foot-with-usage {
|
||||
opacity: 1;
|
||||
}
|
||||
.assistant-turn:hover .msg-foot-with-usage .msg-time,
|
||||
.assistant-turn:hover .msg-foot-with-usage .msg-actions,
|
||||
.assistant-turn:focus-within .msg-foot-with-usage .msg-time,
|
||||
.assistant-turn:focus-within .msg-foot-with-usage .msg-actions,
|
||||
.msg-row[data-role="assistant"]:hover .msg-foot-with-usage .msg-time,
|
||||
.msg-row[data-role="assistant"]:hover .msg-foot-with-usage .msg-actions {
|
||||
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; }
|
||||
|
||||
52
static/ui.js
52
static/ui.js
@@ -1357,6 +1357,25 @@ function _compressionReferenceCardHtml(text, open=false){
|
||||
|
||||
</div>`;
|
||||
}
|
||||
function _isSameLocalDay(dateA, dateB){
|
||||
return dateA.getFullYear()===dateB.getFullYear()
|
||||
&& dateA.getMonth()===dateB.getMonth()
|
||||
&& dateA.getDate()===dateB.getDate();
|
||||
}
|
||||
function _formatMessageFooterTimestamp(tsVal){
|
||||
if(!tsVal) return '';
|
||||
const date=new Date(tsVal*1000);
|
||||
const now=new Date();
|
||||
if(_isSameLocalDay(date, now)){
|
||||
return date.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
}
|
||||
return date.toLocaleString([], {
|
||||
month:'short',
|
||||
day:'numeric',
|
||||
hour:'numeric',
|
||||
minute:'2-digit',
|
||||
});
|
||||
}
|
||||
function _compressionStatusCardHtml({
|
||||
statusLabel,
|
||||
previewText,
|
||||
@@ -1503,9 +1522,9 @@ function renderMessages(){
|
||||
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 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>`;
|
||||
const tsTime=_formatMessageFooterTimestamp(tsVal);
|
||||
const timeHtml = tsTime ? `<span class="msg-time" title="${esc(tsTitle)}">${tsTime}</span>` : '';
|
||||
const footHtml = `<div class="msg-foot">${timeHtml}<span class="msg-actions">${editBtn}${copyBtn}${retryBtn}</span></div>`;
|
||||
|
||||
if(isUser){
|
||||
currentAssistantTurn=null;
|
||||
@@ -1704,21 +1723,26 @@ function renderMessages(){
|
||||
if(anchorRow&&lastInsertedNode) anchorInsertAfter.set(anchorRow, lastInsertedNode);
|
||||
}
|
||||
}
|
||||
// Render usage badge on the last assistant turn (if enabled and usage data exists)
|
||||
// Render cumulative usage on the last assistant footer row (if enabled).
|
||||
if(window._showTokenUsage&&S.session&&(S.session.input_tokens||S.session.output_tokens)){
|
||||
const rows=inner.querySelectorAll('.assistant-turn');
|
||||
let lastAssist=null;
|
||||
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';
|
||||
const inTok=S.session.input_tokens||0;
|
||||
const outTok=S.session.output_tokens||0;
|
||||
const cost=S.session.estimated_cost;
|
||||
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||||
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||||
usage.textContent=text;
|
||||
_assistantTurnBlocks(lastAssist).appendChild(usage);
|
||||
if(lastAssist){
|
||||
const footerRows=lastAssist.querySelectorAll('.msg-foot');
|
||||
const targetFoot=footerRows.length?footerRows[footerRows.length-1]:null;
|
||||
if(targetFoot&&!targetFoot.querySelector('.msg-usage-inline')){
|
||||
const usage=document.createElement('span');
|
||||
usage.className='msg-usage-inline';
|
||||
const inTok=S.session.input_tokens||0;
|
||||
const outTok=S.session.output_tokens||0;
|
||||
const cost=S.session.estimated_cost;
|
||||
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||||
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||||
usage.textContent=text;
|
||||
targetFoot.classList.add('msg-foot-with-usage');
|
||||
targetFoot.insertBefore(usage, targetFoot.firstChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only force-scroll when not actively streaming — mid-stream re-renders
|
||||
|
||||
104
tests/test_sprint49.py
Normal file
104
tests/test_sprint49.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Tests for sprint 49 timestamp footer polish — v0.50.97.
|
||||
|
||||
Covers:
|
||||
- #680: assistant messages now render footer timestamps, not just user messages
|
||||
- messages from prior days render a fuller date+time string in the footer
|
||||
- timestamp/action footer stays attached to visible response segments only
|
||||
- user and assistant footer chrome is hover-only by default
|
||||
- last assistant turn keeps cumulative usage visible and reveals time/actions on hover
|
||||
- unchanged historical messages preserve their original timestamps across turns
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
from api.streaming import _restore_reasoning_metadata
|
||||
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
UI_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_footer_timestamp_is_not_limited_to_user_messages():
|
||||
assert "const timeHtml = tsTime ?" in UI_JS
|
||||
assert "isUser && tsTime" not in UI_JS, (
|
||||
"Timestamp footer should no longer be gated to user messages only"
|
||||
)
|
||||
|
||||
|
||||
def test_footer_timestamp_uses_richer_format_for_older_messages():
|
||||
assert "function _formatMessageFooterTimestamp(tsVal)" in UI_JS
|
||||
assert "month:'short'" in UI_JS or 'month: "short"' in UI_JS
|
||||
assert "day:'numeric'" in UI_JS or 'day: "numeric"' in UI_JS
|
||||
assert "hour:'numeric'" in UI_JS or 'hour: "numeric"' in UI_JS
|
||||
assert "minute:'2-digit'" in UI_JS or 'minute: "2-digit"' in UI_JS
|
||||
|
||||
|
||||
def test_timestamp_footer_stays_on_visible_response_segments():
|
||||
assert "if(hasVisibleBody){" in UI_JS
|
||||
assert 'seg.insertAdjacentHTML(\'beforeend\', `${filesHtml}<div class="msg-body">${bodyHtml}</div>${footHtml}`);' in UI_JS, (
|
||||
"Footer timestamp should stay attached to visible response segments"
|
||||
)
|
||||
assert "else if(!thinkingText){" in UI_JS, (
|
||||
"Thinking-only assistant segments should still avoid rendering a footer"
|
||||
)
|
||||
|
||||
|
||||
def test_footer_chrome_is_hover_only_for_user_and_assistant_messages():
|
||||
assert ".msg-row[data-role=\"user\"] .msg-foot {\n opacity: 0;" in UI_CSS
|
||||
assert ".msg-row[data-role=\"user\"]:hover .msg-foot," in UI_CSS
|
||||
assert ".msg-row[data-role=\"assistant\"] .msg-foot," in UI_CSS
|
||||
assert ".assistant-turn .msg-foot {" in UI_CSS
|
||||
assert ".assistant-turn:hover .msg-foot," in UI_CSS
|
||||
|
||||
|
||||
def test_last_assistant_keeps_usage_visible_and_reveals_time_and_actions_on_hover():
|
||||
assert "usage.className='msg-usage-inline';" in UI_JS
|
||||
assert "targetFoot.classList.add('msg-foot-with-usage');" in UI_JS
|
||||
assert "targetFoot.insertBefore(usage, targetFoot.firstChild);" in UI_JS
|
||||
assert ".assistant-turn .msg-foot-with-usage," in UI_CSS
|
||||
assert ".msg-row[data-role=\"assistant\"] .msg-foot-with-usage {\n opacity: 1;" in UI_CSS
|
||||
assert ".msg-foot-with-usage .msg-time,\n.msg-foot-with-usage .msg-actions {\n opacity: 0;" in UI_CSS
|
||||
assert ".assistant-turn:hover .msg-foot-with-usage .msg-time," in UI_CSS
|
||||
|
||||
|
||||
def test_restore_reasoning_metadata_preserves_existing_timestamps():
|
||||
assert "def _restore_reasoning_metadata(previous_messages, updated_messages):" in STREAMING_PY
|
||||
assert "if prev_msg.get('timestamp') and not cur_msg.get('timestamp'):" in STREAMING_PY
|
||||
assert "cur_msg['timestamp'] = prev_msg['timestamp']" in STREAMING_PY
|
||||
assert "elif prev_msg.get('_ts') and not cur_msg.get('_ts') and not cur_msg.get('timestamp'):" in STREAMING_PY
|
||||
assert "cur_msg['_ts'] = prev_msg['_ts']" in STREAMING_PY
|
||||
|
||||
|
||||
def test_restore_reasoning_metadata_preserves_timestamp_on_reload_for_unchanged_messages():
|
||||
previous_messages = [
|
||||
{"role": "user", "content": "hello", "timestamp": 1713500000},
|
||||
{"role": "assistant", "content": "world", "timestamp": 1713500060},
|
||||
]
|
||||
updated_messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "world"},
|
||||
]
|
||||
|
||||
restored = _restore_reasoning_metadata(previous_messages, updated_messages)
|
||||
|
||||
assert restored[0]["timestamp"] == 1713500000
|
||||
assert restored[1]["timestamp"] == 1713500060
|
||||
|
||||
|
||||
def test_restore_reasoning_metadata_does_not_preserve_timestamp_for_changed_messages():
|
||||
previous_messages = [
|
||||
{"role": "user", "content": "hello", "timestamp": 1713500000},
|
||||
{"role": "assistant", "content": "old answer", "timestamp": 1713500060},
|
||||
]
|
||||
updated_messages = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "new answer"},
|
||||
]
|
||||
|
||||
restored = _restore_reasoning_metadata(previous_messages, updated_messages)
|
||||
|
||||
assert restored[0]["timestamp"] == 1713500000
|
||||
assert "timestamp" not in restored[1]
|
||||
Reference in New Issue
Block a user