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 = `