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:
nesquena-hermes
2026-04-20 00:53:19 -07:00
committed by GitHub
parent a1c5c395e5
commit 711d8bb6c0
5 changed files with 198 additions and 17 deletions

View File

@@ -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; }

View File

@@ -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