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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
Reference in New Issue
Block a user