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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user