diff --git a/CHANGELOG.md b/CHANGELOG.md index 2353aeb..7453cc6 100644 --- a/CHANGELOG.md +++ b/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`) diff --git a/api/models.py b/api/models.py index c70886d..861a08a 100644 --- a/api/models.py +++ b/api/models.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 diff --git a/static/messages.js b/static/messages.js index d2d8d95..48662bd 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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; diff --git a/static/sessions.js b/static/sessions.js index 4d1cf10..00f202c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -10,6 +10,47 @@ const ICONS={ more:'', }; +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); diff --git a/static/style.css b/static/style.css index d9158b6..1f11d42 100644 --- a/static/style.css +++ b/static/style.css @@ -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 ── */ diff --git a/tests/test_issue798.py b/tests/test_issue798.py index cfe5144..fef8a00 100644 --- a/tests/test_issue798.py +++ b/tests/test_issue798.py @@ -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}" diff --git a/tests/test_issue856_active_session_read_state.py b/tests/test_issue856_active_session_read_state.py new file mode 100644 index 0000000..d2a1cb8 --- /dev/null +++ b/tests/test_issue856_active_session_read_state.py @@ -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" + ) diff --git a/tests/test_issue856_pinned_indicator_layout.py b/tests/test_issue856_pinned_indicator_layout.py new file mode 100644 index 0000000..a7c89e0 --- /dev/null +++ b/tests/test_issue856_pinned_indicator_layout.py @@ -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" + ) diff --git a/tests/test_issue856_session_streaming_state.py b/tests/test_issue856_session_streaming_state.py new file mode 100644 index 0000000..e71fd1e --- /dev/null +++ b/tests/test_issue856_session_streaming_state.py @@ -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