feat(ui): session attention indicators — streaming spinner, unread dot, timestamps (#856)

Closes #856. Co-authored-by: Frank Song <138988108+franksong2702@users.noreply.github.com>
Reviewed-by: nesquena (709bd37 — test isolation fix also included)
This commit is contained in:
nesquena-hermes
2026-04-23 09:05:57 -07:00
committed by GitHub
parent 666d385c03
commit b82954ee70
9 changed files with 417 additions and 23 deletions

View File

@@ -1,3 +1,9 @@
function _markSessionViewed(sid, messageCount) {
if(typeof _setSessionViewedCount!=='function' || !sid) return;
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
_setSessionViewedCount(sid, next);
}
async function send(){
const text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length)return;
@@ -104,10 +110,18 @@ async function send(){
}
streamId=startData.stream_id;
S.activeStreamId = streamId;
if(S.session&&S.session.session_id===activeSid){
S.session.active_stream_id = streamId;
}
markInflight(activeSid, streamId);
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:INFLIGHT[activeSid].toolCalls||[]});
}
// Refresh session list so background streaming indicators appear immediately for the
// session that was just started and any others that may already be running.
if(typeof renderSessionList === 'function') {
void renderSessionList();
}
// Show Cancel button
const cancelBtn=$('btnCancel');
if(cancelBtn) cancelBtn.style.display='inline-flex';
@@ -538,6 +552,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.busy=false;
// No-reply guard (#373): if agent returned nothing, show inline error
if(!S.messages.some(m=>m.role==='assistant'&&String(m.content||'').trim())&&!assistantText){removeThinking();S.messages.push({role:'assistant',content:'**No response received.** Check your API key and model selection.'});}
_markSessionViewed(activeSid, d.session.message_count ?? S.messages.length);
syncTopbar();renderMessages();loadDir('.');
}
renderSessionList();setBusy(false);setStatus('');
@@ -592,6 +607,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(_){
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
}
_markSessionViewed(activeSid, S.messages.length);
renderMessages();
}else if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;
@@ -599,6 +615,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
}
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
renderSessionList(); // clear streaming indicator immediately on apperror
});
source.addEventListener('warning',e=>{
@@ -663,6 +680,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.session=data.session;
S.messages=(data.session.messages||[]).filter(m=>m&&m.role);
clearLiveToolCards();if(!assistantText)removeThinking();
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
renderMessages();
}
}catch(_){
@@ -670,6 +688,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(S.session&&S.session.session_id===activeSid){
clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages();
_markSessionViewed(activeSid, S.messages.length);
}
}
})();
@@ -703,6 +722,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}else{
S.toolCalls=[];
}
_markSessionViewed(activeSid, session.message_count ?? S.messages.length);
syncTopbar();renderMessages();
}
renderSessionList();setBusy(false);setComposerStatus('');
@@ -726,6 +746,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
_markSessionViewed(activeSid, S.messages.length);
}else{
if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null;

View File

@@ -10,6 +10,47 @@ const ICONS={
more:'<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" stroke="none"><circle cx="8" cy="3" r="1.25"/><circle cx="8" cy="8" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>',
};
const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';
let _sessionViewedCounts = null;
function _getSessionViewedCounts() {
if (_sessionViewedCounts !== null) return _sessionViewedCounts;
try {
const parsed = JSON.parse(localStorage.getItem(SESSION_VIEWED_COUNTS_KEY) || '{}');
_sessionViewedCounts = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_){
_sessionViewedCounts = {};
}
return _sessionViewedCounts;
}
function _saveSessionViewedCounts() {
try {
localStorage.setItem(SESSION_VIEWED_COUNTS_KEY, JSON.stringify(_getSessionViewedCounts()));
} catch (_){
// Ignore localStorage write failures.
}
}
function _setSessionViewedCount(sid, messageCount = 0) {
if (!sid) return;
const counts = _getSessionViewedCounts();
const next = Number.isFinite(messageCount) ? Number(messageCount) : 0;
counts[sid] = next;
_saveSessionViewedCounts();
}
function _hasUnreadForSession(s) {
if (!s || !s.session_id) return false;
const counts = _getSessionViewedCounts();
if (!Object.prototype.hasOwnProperty.call(counts, s.session_id)) {
_setSessionViewedCount(s.session_id, Number(s.message_count || 0));
return false;
}
if (!Number.isFinite(s.message_count)) return false;
return s.message_count > Number(counts[s.session_id] || 0);
}
async function newSession(flash){
updateQueueBadge();
S.toolCalls=[];
@@ -26,6 +67,7 @@ async function newSession(flash){
S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id);
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
// Reset per-session visual state: a fresh chat is idle even if another
// conversation is still streaming in the background.
S.busy=false;
@@ -46,6 +88,7 @@ async function loadSession(sid){
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
S.session=data.session;
S.lastUsage={...(data.session.last_usage||{})};
_setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0));
localStorage.setItem('hermes-webui-session',S.session.session_id);
data.session.messages = (data.session.messages || []).filter(m => m && m.role);
const hasMessageToolMetadata = (data.session.messages || []).some(m => {
@@ -355,6 +398,13 @@ async function renderSessionList(){
]);
_allSessions = sessData.sessions||[];
_allProjects = projData.projects||[];
const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming));
if (isStreaming) {
startStreamingPoll();
} else {
stopStreamingPoll();
}
ensureSessionTimeRefreshPoll();
renderSessionListFromCache(); // no-ops if rename is in progress
}catch(e){console.warn('renderSessionList',e);}
}
@@ -365,6 +415,30 @@ let _gatewayPollTimer = null;
let _gatewayProbeInFlight = false;
let _gatewaySSEWarningShown = false;
const _gatewayFallbackPollMs = 30000;
const _streamingPollMs = 5000;
const _sessionTimeRefreshMs = 60000;
let _streamingPollTimer = null;
let _sessionTimeRefreshTimer = null;
function startStreamingPoll(){
if(_streamingPollTimer) return;
_streamingPollTimer = setInterval(() => {
void renderSessionList();
}, _streamingPollMs);
}
function stopStreamingPoll(){
if(!_streamingPollTimer) return;
clearInterval(_streamingPollTimer);
_streamingPollTimer = null;
}
function ensureSessionTimeRefreshPoll(){
if(_sessionTimeRefreshTimer) return;
_sessionTimeRefreshTimer = setInterval(() => {
renderSessionListFromCache();
}, _sessionTimeRefreshMs);
}
function startGatewayPollFallback(ms){
const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs);
@@ -693,7 +767,9 @@ function renderSessionListFromCache(){
function _renderOneSession(s){
const el=document.createElement('div');
const isActive=S.session&&s.session_id===S.session.session_id;
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'');
const isStreaming=Boolean(s.is_streaming);
const hasUnread=_hasUnreadForSession(s)&&!isActive;
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'');
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
const rawTitle=s.title||'Untitled';
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
@@ -706,12 +782,25 @@ function renderSessionListFromCache(){
sessionText.className='session-text';
const titleRow=document.createElement('div');
titleRow.className='session-title-row';
if(s.pinned){
const pinInd=document.createElement('span');
pinInd.className='session-pin-indicator';
pinInd.innerHTML=ICONS.pin;
titleRow.appendChild(pinInd);
}
const state=document.createElement('span');
state.className='session-state-indicator'+(isStreaming?' is-streaming':(hasUnread?' is-unread':''));
titleRow.appendChild(state); // always reserve slot — prevents title shift when indicator appears
const title=document.createElement('span');
title.className='session-title';
title.textContent=cleanTitle||'Untitled';
title.title='Double-click to rename';
const tsMs=_sessionTimestampMs(s);
const ts=document.createElement('span');
ts.className='session-time';
ts.textContent=_formatRelativeSessionTime(tsMs);
titleRow.appendChild(title);
titleRow.appendChild(ts);
sessionText.appendChild(titleRow);
const density=(window._sidebarDensity==='detailed'?'detailed':'compact');
if(density==='detailed'){
@@ -781,13 +870,6 @@ function renderSessionListFromCache(){
setTimeout(()=>{inp.focus();inp.select();},10);
};
// Pin indicator (inline, only when pinned — no space reserved otherwise)
if(s.pinned){
const pinInd=document.createElement('span');
pinInd.className='session-pin-indicator';
pinInd.innerHTML=ICONS.pin;
el.appendChild(pinInd);
}
// Project indicator: colored dot appended after the title
if(s.project_id){
const proj=_allProjects.find(p=>p.project_id===s.project_id);

View File

@@ -119,8 +119,8 @@
:root:not(.dark) .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
:root:not(.dark) .session-item.active{background:var(--accent-bg);color:var(--accent-text);}
:root:not(.dark) .session-item.active .session-title{color:var(--accent-text);}
:root:not(.dark) .session-pin-indicator{color:#996b15;}
:root:not(.dark) .session-date-header.pinned{color:#996b15;}
:root:not(.dark) .session-pin-indicator{color:var(--accent-text);}
:root:not(.dark) .session-date-header.pinned{color:var(--accent-text);}
:root:not(.dark) .session-actions-trigger.active,
:root:not(.dark) .session-item.menu-open .session-actions-trigger{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--accent-text);}
:root:not(.dark) .session-action-opt.is-active{background:var(--accent-bg);}
@@ -224,13 +224,40 @@
.session-item{padding:8px 40px 8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;}
.session-item:hover{background:var(--hover-bg);color:var(--text);}
.session-item.active{background:var(--accent-bg);color:var(--accent);}
.session-item.streaming .session-title{color:var(--accent);}
.session-item.streaming .session-title-row{color:var(--text);}
.session-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow:hidden;}
.session-title-row{display:flex;align-items:center;gap:6px;min-width:0;}
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);}
.session-item.active .session-title{color:var(--accent-text);}
.session-meta{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.session-item.active .session-meta{color:var(--accent-text);opacity:.8;}
.session-time{display:none;}
.session-state-indicator{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;width:10px;height:10px;color:var(--accent);visibility:hidden;}
.session-state-indicator.is-streaming,.session-state-indicator.is-unread{visibility:visible;}
.session-state-indicator::before{content:"";display:block;flex-shrink:0;}
.session-state-indicator.is-streaming::before{
width:100%;
height:100%;
border:2px solid transparent;
border-top-color:currentColor;
border-right-color:currentColor;
border-radius:50%;
animation:spin 1s linear infinite;
}
.session-state-indicator.is-unread::before{
width:8px;
height:8px;
border-radius:50%;
background:currentColor;
}
.session-time{
display:inline-flex;
margin-left:auto;
color:var(--muted);
font-size:10px;
white-space:nowrap;
flex-shrink:0;
}
/* ── Session action trigger + dropdown ── */
.session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;}
.session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;}
@@ -252,11 +279,12 @@
/* Hide overlay during inline rename */
.session-item:has(.session-title-input) .session-actions{display:none;}
@keyframes newflash{0%{background:var(--accent-bg-strong);color:var(--accent);}100%{background:transparent;color:var(--muted);}}
@keyframes spin{to{transform:rotate(360deg);}}
.session-item.new-flash{animation:newflash 1.4s ease-out forwards;}
/* Collapsible date group headers */
.session-date-header{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 10px 4px;cursor:pointer;user-select:none;opacity:.8;transition:opacity .15s;}
.session-date-header:hover{opacity:1;}
.session-date-header.pinned{color:#f5c542;}
.session-date-header.pinned{color:var(--accent);}
.session-date-caret{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
.session-date-caret.collapsed{transform:rotate(-90deg);}
.app-dialog-overlay{position:fixed;inset:0;background:rgba(7,12,19,.62);backdrop-filter:blur(6px);z-index:1100;display:none;align-items:center;justify-content:center;padding:24px;}
@@ -1481,7 +1509,16 @@ body.resizing{user-select:none;cursor:col-resize;}
.provider-card .sm-btn:disabled{opacity:.4;cursor:not-allowed;}
/* ── Session pin indicator (inline, only when pinned) ── */
.session-pin-indicator{flex-shrink:0;color:#f5c542;line-height:1;display:flex;align-items:center;}
.session-pin-indicator{
flex-shrink:0;
width:10px;
height:10px;
color:var(--accent);
line-height:1;
display:inline-flex;
align-items:center;
justify-content:center;
}
.session-pin-indicator svg{width:10px;height:10px;}
/* ── Cron alert badge ── */