diff --git a/CHANGELOG.md b/CHANGELOG.md index c324fdd..81354d7 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/api/streaming.py b/api/streaming.py index 6bd7201..d8cb593 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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 diff --git a/static/style.css b/static/style.css index e1e2ec6..3b565ae 100644 --- a/static/style.css +++ b/static/style.css @@ -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; } diff --git a/static/ui.js b/static/ui.js index e33123f..a281b7d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1357,6 +1357,25 @@ function _compressionReferenceCardHtml(text, open=false){ `; } +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 = ``; 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) ? `${tsTime}` : ''; - const footHtml = `
${userTimeHtml}${editBtn}${copyBtn}${retryBtn}
`; + const tsTime=_formatMessageFooterTimestamp(tsVal); + const timeHtml = tsTime ? `${tsTime}` : ''; + const footHtml = `
${timeHtml}${editBtn}${copyBtn}${retryBtn}
`; 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 diff --git a/tests/test_sprint49.py b/tests/test_sprint49.py new file mode 100644 index 0000000..aed916c --- /dev/null +++ b/tests/test_sprint49.py @@ -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}
${bodyHtml}
${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]