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:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,21 @@
|
||||
|
||||
## [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
|
||||
- **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`)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from pathlib import Path
|
||||
import api.config as _cfg
|
||||
from api.config import (
|
||||
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,
|
||||
)
|
||||
from api.workspace import get_last_workspace
|
||||
@@ -103,6 +103,15 @@ def _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:
|
||||
def __init__(self, session_id: str=None, title: str='Untitled',
|
||||
workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL,
|
||||
@@ -165,7 +174,8 @@ class Session:
|
||||
return None
|
||||
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 {
|
||||
'session_id': self.session_id,
|
||||
'title': self.title,
|
||||
@@ -184,6 +194,10 @@ class Session:
|
||||
'personality': self.personality,
|
||||
'compression_anchor_visible_idx': self.compression_anchor_visible_idx,
|
||||
'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):
|
||||
@@ -232,6 +246,7 @@ def new_session(workspace=None, model=None, profile=None):
|
||||
return s
|
||||
|
||||
def all_sessions():
|
||||
active_stream_ids = _active_stream_ids()
|
||||
# Phase C: try index first for O(1) read; fall back to full scan
|
||||
if SESSION_INDEX_FILE.exists():
|
||||
try:
|
||||
@@ -240,11 +255,19 @@ def all_sessions():
|
||||
s for s in index
|
||||
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
|
||||
index_map = {s['session_id']: s for s in index}
|
||||
with LOCK:
|
||||
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)
|
||||
# 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)
|
||||
@@ -275,7 +298,7 @@ def all_sessions():
|
||||
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)
|
||||
_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'
|
||||
and len(s.messages) == 0
|
||||
and (_now - s.updated_at) > 60
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -133,18 +133,24 @@ def test_concurrent_new_sessions_get_correct_profiles():
|
||||
results = {}
|
||||
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):
|
||||
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
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
t1 = threading.Thread(target=make_session, args=('alice', 'alice'))
|
||||
t2 = threading.Thread(target=make_session, args=('bob', 'bob'))
|
||||
t1.start(); t2.start()
|
||||
t1.join(timeout=5); t2.join(timeout=5)
|
||||
with patch.object(m.Session, 'save', return_value=None):
|
||||
t1 = threading.Thread(target=make_session, args=('alice', 'alice'))
|
||||
t2 = threading.Thread(target=make_session, args=('bob', 'bob'))
|
||||
t1.start(); t2.start()
|
||||
t1.join(timeout=5); t2.join(timeout=5)
|
||||
|
||||
assert not errors, f"Threads raised: {errors}"
|
||||
assert results.get('alice') == 'alice', f"alice session had profile {results.get('alice')!r}"
|
||||
|
||||
49
tests/test_issue856_active_session_read_state.py
Normal file
49
tests/test_issue856_active_session_read_state.py
Normal 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"
|
||||
)
|
||||
68
tests/test_issue856_pinned_indicator_layout.py
Normal file
68
tests/test_issue856_pinned_indicator_layout.py
Normal 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"
|
||||
)
|
||||
93
tests/test_issue856_session_streaming_state.py
Normal file
93
tests/test_issue856_session_streaming_state.py
Normal 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
|
||||
Reference in New Issue
Block a user