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

@@ -2,6 +2,21 @@
## [Unreleased] ## [Unreleased]
### Added
- **Session attention indicators in the sidebar** — the session list now shows a
spinning indicator while a session is actively streaming (even in the
background), an unread dot when a session has new messages the user hasn't
seen, and a right-aligned relative timestamp ("2m ago", "Yesterday") next to
every session title. Streaming state is computed server-side from the live
`STREAMS` registry so it's accurate across tabs and after server restart.
The unread count is tracked client-side in `localStorage` and cleared
automatically when the active session's stream settles. Pinned-star indicator
moved into the title row with a fixed 10×10 box for consistent alignment.
Includes a 5 s polling loop that activates only while sessions are streaming,
and a 60 s timer to keep relative timestamps fresh. (`api/models.py`,
`static/sessions.js`, `static/messages.js`, `static/style.css`) Closes #856.
Co-authored by @franksong2702.
### Fixed ### Fixed
- **Nous static models now use explicit `@nous:` prefix** — the four hardcoded "(via Nous)" models (`Claude Opus 4.6`, `Claude Sonnet 4.6`, `GPT-5.4 Mini`, `Gemini 3.1 Pro Preview`) now carry `@nous:` prefix IDs, matching the format of live-fetched Nous models. Previously they used slash-only IDs that relied on the portal provider guard; the explicit prefix routes them through the same bulletproof `@provider:model` branch and eliminates 404 errors on those entries. (`api/config.py`, `tests/test_nous_portal_routing.py`) - **Nous static models now use explicit `@nous:` prefix** — the four hardcoded "(via Nous)" models (`Claude Opus 4.6`, `Claude Sonnet 4.6`, `GPT-5.4 Mini`, `Gemini 3.1 Pro Preview`) now carry `@nous:` prefix IDs, matching the format of live-fetched Nous models. Previously they used slash-only IDs that relied on the portal provider guard; the explicit prefix routes them through the same bulletproof `@provider:model` branch and eliminates 404 errors on those entries. (`api/config.py`, `tests/test_nous_portal_routing.py`)

View File

@@ -12,7 +12,7 @@ from pathlib import Path
import api.config as _cfg import api.config as _cfg
from api.config import ( from api.config import (
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX, SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME, LOCK, STREAMS, STREAMS_LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME,
get_effective_default_model, get_effective_default_model,
) )
from api.workspace import get_last_workspace from api.workspace import get_last_workspace
@@ -103,6 +103,15 @@ def _write_session_index(updates=None):
_write_session_index(updates=None) _write_session_index(updates=None)
def _active_stream_ids():
with STREAMS_LOCK:
return set(STREAMS.keys())
def _is_streaming_session(active_stream_id, active_stream_ids):
return bool(active_stream_id and active_stream_id in active_stream_ids)
class Session: class Session:
def __init__(self, session_id: str=None, title: str='Untitled', def __init__(self, session_id: str=None, title: str='Untitled',
workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL,
@@ -165,7 +174,8 @@ class Session:
return None return None
return cls(**json.loads(p.read_text(encoding='utf-8'))) return cls(**json.loads(p.read_text(encoding='utf-8')))
def compact(self) -> dict: def compact(self, include_runtime=False, active_stream_ids=None) -> dict:
active_stream_ids = active_stream_ids if active_stream_ids is not None else set()
return { return {
'session_id': self.session_id, 'session_id': self.session_id,
'title': self.title, 'title': self.title,
@@ -184,6 +194,10 @@ class Session:
'personality': self.personality, 'personality': self.personality,
'compression_anchor_visible_idx': self.compression_anchor_visible_idx, 'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
'compression_anchor_message_key': self.compression_anchor_message_key, 'compression_anchor_message_key': self.compression_anchor_message_key,
'active_stream_id': self.active_stream_id,
'is_streaming': _is_streaming_session(
self.active_stream_id, active_stream_ids
) if include_runtime else False,
} }
def get_session(sid): def get_session(sid):
@@ -232,6 +246,7 @@ def new_session(workspace=None, model=None, profile=None):
return s return s
def all_sessions(): def all_sessions():
active_stream_ids = _active_stream_ids()
# Phase C: try index first for O(1) read; fall back to full scan # Phase C: try index first for O(1) read; fall back to full scan
if SESSION_INDEX_FILE.exists(): if SESSION_INDEX_FILE.exists():
try: try:
@@ -240,11 +255,19 @@ def all_sessions():
s for s in index s for s in index
if _index_entry_exists(s.get('session_id')) if _index_entry_exists(s.get('session_id'))
] ]
for s in index:
s['is_streaming'] = _is_streaming_session(
s.get('active_stream_id'),
active_stream_ids,
)
# Overlay any in-memory sessions that may be newer than the index # Overlay any in-memory sessions that may be newer than the index
index_map = {s['session_id']: s for s in index} index_map = {s['session_id']: s for s in index}
with LOCK: with LOCK:
for s in SESSIONS.values(): for s in SESSIONS.values():
index_map[s.session_id] = s.compact() index_map[s.session_id] = s.compact(
include_runtime=True,
active_stream_ids=active_stream_ids,
)
result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True) result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True)
# Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.) # Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.)
# Exempt sessions younger than 60 s so a brand-new session stays visible (#789) # Exempt sessions younger than 60 s so a brand-new session stays visible (#789)
@@ -275,7 +298,7 @@ def all_sessions():
if all(s.session_id != x.session_id for x in out): out.append(s) if all(s.session_id != x.session_id for x in out): out.append(s)
out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True) out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True)
_now = time.time() _now = time.time()
result = [s.compact() for s in out if not ( result = [s.compact(include_runtime=True, active_stream_ids=active_stream_ids) for s in out if not (
s.title == 'Untitled' s.title == 'Untitled'
and len(s.messages) == 0 and len(s.messages) == 0
and (_now - s.updated_at) > 60 and (_now - s.updated_at) > 60

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(){ async function send(){
const text=$('msg').value.trim(); const text=$('msg').value.trim();
if(!text&&!S.pendingFiles.length)return; if(!text&&!S.pendingFiles.length)return;
@@ -104,10 +110,18 @@ async function send(){
} }
streamId=startData.stream_id; streamId=startData.stream_id;
S.activeStreamId = streamId; S.activeStreamId = streamId;
if(S.session&&S.session.session_id===activeSid){
S.session.active_stream_id = streamId;
}
markInflight(activeSid, streamId); markInflight(activeSid, streamId);
if(typeof saveInflightState==='function'){ if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded,toolCalls:INFLIGHT[activeSid].toolCalls||[]}); 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 // Show Cancel button
const cancelBtn=$('btnCancel'); const cancelBtn=$('btnCancel');
if(cancelBtn) cancelBtn.style.display='inline-flex'; if(cancelBtn) cancelBtn.style.display='inline-flex';
@@ -538,6 +552,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.busy=false; S.busy=false;
// No-reply guard (#373): if agent returned nothing, show inline error // 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.'});} 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('.'); syncTopbar();renderMessages();loadDir('.');
} }
renderSessionList();setBusy(false);setStatus(''); renderSessionList();setBusy(false);setStatus('');
@@ -592,6 +607,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}catch(_){ }catch(_){
S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'}); S.messages.push({role:'assistant',content:'**Error:** An error occurred. Check server logs.'});
} }
_markSessionViewed(activeSid, S.messages.length);
renderMessages(); renderMessages();
}else if(typeof trackBackgroundError==='function'){ }else if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null; 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');} catch(_){trackBackgroundError(activeSid,_errTitle,'Error');}
} }
if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');} if(!S.session||!INFLIGHT[S.session.session_id]){setBusy(false);setComposerStatus('');}
renderSessionList(); // clear streaming indicator immediately on apperror
}); });
source.addEventListener('warning',e=>{ source.addEventListener('warning',e=>{
@@ -663,6 +680,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.session=data.session; S.session=data.session;
S.messages=(data.session.messages||[]).filter(m=>m&&m.role); S.messages=(data.session.messages||[]).filter(m=>m&&m.role);
clearLiveToolCards();if(!assistantText)removeThinking(); clearLiveToolCards();if(!assistantText)removeThinking();
_markSessionViewed(activeSid, data.session.message_count ?? S.messages.length);
renderMessages(); renderMessages();
} }
}catch(_){ }catch(_){
@@ -670,6 +688,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(S.session&&S.session.session_id===activeSid){ if(S.session&&S.session.session_id===activeSid){
clearLiveToolCards();if(!assistantText)removeThinking(); clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'*Task cancelled.*'});renderMessages(); 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{ }else{
S.toolCalls=[]; S.toolCalls=[];
} }
_markSessionViewed(activeSid, session.message_count ?? S.messages.length);
syncTopbar();renderMessages(); syncTopbar();renderMessages();
} }
renderSessionList();setBusy(false);setComposerStatus(''); 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'; S.activeStreamId=null;const _cbe=$('btnCancel');if(_cbe)_cbe.style.display='none';
clearLiveToolCards();if(!assistantText)removeThinking(); clearLiveToolCards();if(!assistantText)removeThinking();
S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages(); S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages();
_markSessionViewed(activeSid, S.messages.length);
}else{ }else{
if(typeof trackBackgroundError==='function'){ if(typeof trackBackgroundError==='function'){
const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null; 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>', 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){ async function newSession(flash){
updateQueueBadge(); updateQueueBadge();
S.toolCalls=[]; S.toolCalls=[];
@@ -26,6 +67,7 @@ async function newSession(flash){
S.lastUsage={...(data.session.last_usage||{})}; S.lastUsage={...(data.session.last_usage||{})};
if(flash)S.session._flash=true; if(flash)S.session._flash=true;
localStorage.setItem('hermes-webui-session',S.session.session_id); 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 // Reset per-session visual state: a fresh chat is idle even if another
// conversation is still streaming in the background. // conversation is still streaming in the background.
S.busy=false; S.busy=false;
@@ -46,6 +88,7 @@ async function loadSession(sid){
const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`); const data=await api(`/api/session?session_id=${encodeURIComponent(sid)}`);
S.session=data.session; S.session=data.session;
S.lastUsage={...(data.session.last_usage||{})}; 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); localStorage.setItem('hermes-webui-session',S.session.session_id);
data.session.messages = (data.session.messages || []).filter(m => m && m.role); data.session.messages = (data.session.messages || []).filter(m => m && m.role);
const hasMessageToolMetadata = (data.session.messages || []).some(m => { const hasMessageToolMetadata = (data.session.messages || []).some(m => {
@@ -355,6 +398,13 @@ async function renderSessionList(){
]); ]);
_allSessions = sessData.sessions||[]; _allSessions = sessData.sessions||[];
_allProjects = projData.projects||[]; _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 renderSessionListFromCache(); // no-ops if rename is in progress
}catch(e){console.warn('renderSessionList',e);} }catch(e){console.warn('renderSessionList',e);}
} }
@@ -365,6 +415,30 @@ let _gatewayPollTimer = null;
let _gatewayProbeInFlight = false; let _gatewayProbeInFlight = false;
let _gatewaySSEWarningShown = false; let _gatewaySSEWarningShown = false;
const _gatewayFallbackPollMs = 30000; 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){ function startGatewayPollFallback(ms){
const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs); const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs);
@@ -693,7 +767,9 @@ function renderSessionListFromCache(){
function _renderOneSession(s){ function _renderOneSession(s){
const el=document.createElement('div'); const el=document.createElement('div');
const isActive=S.session&&s.session_id===S.session.session_id; 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; if(isActive&&S.session&&S.session._flash)delete S.session._flash;
const rawTitle=s.title||'Untitled'; const rawTitle=s.title||'Untitled';
const tags=(rawTitle.match(/#[\w-]+/g)||[]); const tags=(rawTitle.match(/#[\w-]+/g)||[]);
@@ -706,12 +782,25 @@ function renderSessionListFromCache(){
sessionText.className='session-text'; sessionText.className='session-text';
const titleRow=document.createElement('div'); const titleRow=document.createElement('div');
titleRow.className='session-title-row'; 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'); const title=document.createElement('span');
title.className='session-title'; title.className='session-title';
title.textContent=cleanTitle||'Untitled'; title.textContent=cleanTitle||'Untitled';
title.title='Double-click to rename'; title.title='Double-click to rename';
const tsMs=_sessionTimestampMs(s); const tsMs=_sessionTimestampMs(s);
const ts=document.createElement('span');
ts.className='session-time';
ts.textContent=_formatRelativeSessionTime(tsMs);
titleRow.appendChild(title); titleRow.appendChild(title);
titleRow.appendChild(ts);
sessionText.appendChild(titleRow); sessionText.appendChild(titleRow);
const density=(window._sidebarDensity==='detailed'?'detailed':'compact'); const density=(window._sidebarDensity==='detailed'?'detailed':'compact');
if(density==='detailed'){ if(density==='detailed'){
@@ -781,13 +870,6 @@ function renderSessionListFromCache(){
setTimeout(()=>{inp.focus();inp.select();},10); 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 // Project indicator: colored dot appended after the title
if(s.project_id){ if(s.project_id){
const proj=_allProjects.find(p=>p.project_id===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: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{background:var(--accent-bg);color:var(--accent-text);}
:root:not(.dark) .session-item.active .session-title{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-pin-indicator{color:var(--accent-text);}
:root:not(.dark) .session-date-header.pinned{color:#996b15;} :root:not(.dark) .session-date-header.pinned{color:var(--accent-text);}
:root:not(.dark) .session-actions-trigger.active, :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-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);} :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{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:hover{background:var(--hover-bg);color:var(--text);}
.session-item.active{background:var(--accent-bg);color:var(--accent);} .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-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-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-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);}
.session-item.active .session-title{color:var(--accent-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-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-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 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-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;} .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 */ /* Hide overlay during inline rename */
.session-item:has(.session-title-input) .session-actions{display:none;} .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 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;} .session-item.new-flash{animation:newflash 1.4s ease-out forwards;}
/* Collapsible date group headers */ /* 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{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: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{font-size:9px;transition:transform .2s;flex-shrink:0;display:inline-block;}
.session-date-caret.collapsed{transform:rotate(-90deg);} .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;} .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;} .provider-card .sm-btn:disabled{opacity:.4;cursor:not-allowed;}
/* ── Session pin indicator (inline, only when pinned) ── */ /* ── 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;} .session-pin-indicator svg{width:10px;height:10px;}
/* ── Cron alert badge ── */ /* ── Cron alert badge ── */

View File

@@ -133,18 +133,24 @@ def test_concurrent_new_sessions_get_correct_profiles():
results = {} results = {}
errors = [] errors = []
# Patch Session.save ONCE around both threads — not once per thread.
# Per-thread `with patch.object(...)` nested across threads has a known
# concurrency bug in unittest.mock where one thread's __exit__ can capture
# the other thread's mock as the "original" and leave the class attribute
# permanently pointing at a MagicMock, breaking every later test that
# calls Session.save (any test writing a real session file).
def make_session(profile_name, key): def make_session(profile_name, key):
try: try:
with patch.object(m.Session, 'save', return_value=None): s = m.new_session(profile=profile_name)
s = m.new_session(profile=profile_name)
results[key] = s.profile results[key] = s.profile
except Exception as exc: except Exception as exc:
errors.append(exc) errors.append(exc)
t1 = threading.Thread(target=make_session, args=('alice', 'alice')) with patch.object(m.Session, 'save', return_value=None):
t2 = threading.Thread(target=make_session, args=('bob', 'bob')) t1 = threading.Thread(target=make_session, args=('alice', 'alice'))
t1.start(); t2.start() t2 = threading.Thread(target=make_session, args=('bob', 'bob'))
t1.join(timeout=5); t2.join(timeout=5) t1.start(); t2.start()
t1.join(timeout=5); t2.join(timeout=5)
assert not errors, f"Threads raised: {errors}" assert not errors, f"Threads raised: {errors}"
assert results.get('alice') == 'alice', f"alice session had profile {results.get('alice')!r}" assert results.get('alice') == 'alice', f"alice session had profile {results.get('alice')!r}"

View File

@@ -0,0 +1,49 @@
"""Regression checks for #856 active-session unread state handling."""
from pathlib import Path
MESSAGES_JS = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()
def test_messages_js_defines_active_session_viewed_helper():
assert "function _markSessionViewed(" in MESSAGES_JS, (
"messages.js should define a helper that marks the active session as viewed"
)
assert "_setSessionViewedCount" in MESSAGES_JS, (
"active-session viewed helper must delegate to the sidebar viewed-count store"
)
def test_done_path_marks_active_session_as_viewed():
done_idx = MESSAGES_JS.find("source.addEventListener('done'")
assert done_idx != -1, "done handler not found in messages.js"
done_block = MESSAGES_JS[done_idx:MESSAGES_JS.find("source.addEventListener('stream_end'", done_idx)]
assert "_markSessionViewed(activeSid" in done_block, (
"done handler must mark the active session as viewed so unread dot does not linger"
)
def test_cancel_path_marks_active_session_as_viewed():
cancel_idx = MESSAGES_JS.find("source.addEventListener('cancel'")
assert cancel_idx != -1, "cancel handler not found in messages.js"
cancel_block = MESSAGES_JS[cancel_idx:MESSAGES_JS.find("async function _restoreSettledSession()", cancel_idx)]
assert "_markSessionViewed(activeSid" in cancel_block, (
"cancel handler must mark the active session as viewed after settling messages"
)
def test_restore_and_error_paths_mark_active_session_as_viewed():
restore_idx = MESSAGES_JS.find("async function _restoreSettledSession()")
assert restore_idx != -1, "_restoreSettledSession() not found in messages.js"
restore_block = MESSAGES_JS[restore_idx:MESSAGES_JS.find("function _handleStreamError()", restore_idx)]
assert "_markSessionViewed(activeSid" in restore_block, (
"_restoreSettledSession() must mark the active session as viewed"
)
error_idx = MESSAGES_JS.find("function _handleStreamError()")
assert error_idx != -1, "_handleStreamError() not found in messages.js"
error_block = MESSAGES_JS[error_idx:]
assert "_markSessionViewed(activeSid" in error_block, (
"_handleStreamError() must mark the active session as viewed"
)

View File

@@ -0,0 +1,68 @@
"""Regression checks for #856 pinned-star layout in the session list."""
from pathlib import Path
SESSIONS_JS = (Path(__file__).resolve().parent.parent / "static" / "sessions.js").read_text()
STYLE_CSS = (Path(__file__).resolve().parent.parent / "static" / "style.css").read_text()
def test_pinned_indicator_renders_inside_title_row():
title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';")
assert title_row_idx != -1, "session title row construction not found"
pin_idx = SESSIONS_JS.find("pinInd.className='session-pin-indicator';", title_row_idx)
assert pin_idx != -1, "pinned indicator creation not found after title row"
append_to_title_row_idx = SESSIONS_JS.find("titleRow.appendChild(pinInd);", pin_idx)
assert append_to_title_row_idx != -1, "pinned indicator should be appended to titleRow"
append_to_el_idx = SESSIONS_JS.find("el.appendChild(pinInd);", pin_idx)
assert append_to_el_idx == -1, (
"pinned indicator should not be appended to the outer session row; "
"it must align inside the title row with the spinner/unread indicator"
)
def test_pinned_indicator_uses_fixed_indicator_box():
assert ".session-pin-indicator{" in STYLE_CSS, "session pin indicator CSS block missing"
css_block = STYLE_CSS[STYLE_CSS.find(".session-pin-indicator{"):STYLE_CSS.find(".session-pin-indicator svg{")]
assert "width:10px;" in css_block, "pin indicator should reserve a fixed 10px width"
assert "height:10px;" in css_block, "pin indicator should reserve a fixed 10px height"
assert "justify-content:center;" in css_block, "pin indicator should center the star inside its box"
def test_state_indicator_always_appended_to_prevent_layout_shift():
"""State span is always added to the DOM (visibility:hidden when inactive) to prevent
titles shifting left/right when the spinner or unread dot appears/disappears."""
title_row_idx = SESSIONS_JS.find("titleRow.className='session-title-row';")
assert title_row_idx != -1, "title row construction not found"
# state span must be appended unconditionally (no surrounding if-check)
append_idx = SESSIONS_JS.find("titleRow.appendChild(state);", title_row_idx)
assert append_idx != -1, "state span must always be appended to titleRow"
# Verify CSS uses visibility:hidden to reserve the slot
assert "session-state-indicator{" in STYLE_CSS, "session-state-indicator CSS rule missing"
base_block_start = STYLE_CSS.find("session-state-indicator{")
base_block_end = STYLE_CSS.find("}", base_block_start)
base_block = STYLE_CSS[base_block_start:base_block_end]
assert "visibility:hidden;" in base_block, (
"session-state-indicator should default to visibility:hidden so it reserves slot "
"without being visible — prevents title layout shift on state changes"
)
def test_apperror_path_calls_render_session_list():
"""apperror handler must call renderSessionList() to clear the streaming indicator
immediately rather than waiting for the 5s streaming poll interval."""
messages_js = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()
apperror_idx = messages_js.find("source.addEventListener('apperror'")
assert apperror_idx != -1, "apperror handler not found in messages.js"
warning_idx = messages_js.find("source.addEventListener('warning'", apperror_idx)
assert warning_idx != -1, "warning handler not found after apperror handler"
apperror_block = messages_js[apperror_idx:warning_idx]
assert "renderSessionList()" in apperror_block, (
"apperror handler must call renderSessionList() so the streaming indicator "
"clears immediately on server errors, not after a 5s poll delay"
)

View File

@@ -0,0 +1,93 @@
"""
Regression tests for session streaming indicator payloads used by the session list.
This ensures backend payloads report per-session streaming status from active stream
tracking, not only for the foreground conversation.
"""
import threading
import pytest
import api.models as models
from api.models import Session, all_sessions
@pytest.fixture(autouse=True)
def _isolate_session_stream_state(tmp_path, monkeypatch):
"""Keep session/index/stream state isolated from the host environment."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
models.SESSIONS.clear()
stream_map = {}
stream_lock = threading.Lock()
monkeypatch.setattr(models, "STREAMS", stream_map)
monkeypatch.setattr(models, "STREAMS_LOCK", stream_lock)
yield
models.SESSIONS.clear()
def _make_session(session_id, stream_id=None, message_count=1):
s = Session(
session_id=session_id,
title=session_id,
messages=[{"role": "user", "content": f"seed-{session_id}"}] * message_count,
)
s.active_stream_id = stream_id
return s
def test_all_sessions_marks_indexed_and_in_memory_streaming_sessions():
"""Session records from both index and in-memory cache should expose is_streaming."""
s_disk = _make_session("disk_session", stream_id="stream-1")
s_disk.save()
s_memory = _make_session("memory_session", stream_id="stream-2")
with models.LOCK:
models.SESSIONS[s_memory.session_id] = s_memory
models.STREAMS["stream-1"] = object()
models.STREAMS["stream-2"] = object()
listed = all_sessions()
by_sid = {s["session_id"]: s for s in listed}
assert by_sid["disk_session"]["is_streaming"] is True
assert by_sid["memory_session"]["is_streaming"] is True
assert by_sid["memory_session"]["active_stream_id"] == "stream-2"
def test_all_sessions_marks_streaming_false_when_stream_is_not_active():
"""Stale active_stream_id should not imply streaming without active STREAMS entry."""
s = _make_session("stalesession", stream_id="stale-stream")
s.save()
assert all_sessions()[0]["is_streaming"] is False
models.STREAMS["stale-stream"] = object()
assert all_sessions()[0]["is_streaming"] is True
models.STREAMS.pop("stale-stream", None)
assert all_sessions()[0]["is_streaming"] is False
def test_all_sessions_does_not_report_streaming_after_restart_without_active_registry():
"""Server restarts should not resurrect sidebar streaming state from disk alone."""
s = _make_session("restart_session", stream_id="old-stream")
s.save()
models.SESSIONS.clear()
reloaded = Session.load("restart_session")
assert reloaded is not None
assert reloaded.active_stream_id == "old-stream"
listed = all_sessions()
assert listed[0]["active_stream_id"] == "old-stream"
assert listed[0]["is_streaming"] is False