From 912925d6e81f1d29dbe34deae267f1e94da294c3 Mon Sep 17 00:00:00 2001 From: santastabber <197905764+santastabber@users.noreply.github.com> Date: Sat, 20 Jun 2026 07:47:18 +1000 Subject: [PATCH 01/45] fix(sidebar): bubble hidden lineage stream state --- CHANGELOG.md | 4 + static/sessions.js | 59 +++- .../test_issue2454_active_session_spinner.py | 36 ++ ...t_issue856_background_completion_unread.py | 132 +++++++- tests/test_session_lineage_collapse.py | 308 +++++++++++++++++- tests/test_sidebar_pin_immediate_reorder.py | 25 ++ 6 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 tests/test_sidebar_pin_immediate_reorder.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a145d3c..65742208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Sidebar lineage rows now keep their running spinner, latest activity ordering, and unread/attention state when the active continuation is hidden as an archived lineage child. Pin/unpin actions also update the sidebar ordering immediately before the next server refresh. + ## [v0.51.523] — 2026-06-19 — Release SH (cache trusted wiki page listings on browser reads) ### Changed diff --git a/static/sessions.js b/static/sessions.js index 631e9253..e2926567 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -368,7 +368,11 @@ function _isSessionLocallyStreaming(s) { } function _isSessionEffectivelyStreaming(s) { - return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s))); + return Boolean(s && ( + s.is_streaming || + _hasPendingUserMessageSignal(s) || + _isSessionLocallyStreaming(s) + )); } function _hasPendingUserMessageSignal(s) { @@ -376,7 +380,7 @@ function _hasPendingUserMessageSignal(s) { } function _isServerIdleSessionRow(s) { - return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message && !s.has_pending_user_message); + return Boolean(s && s.session_id && !s.is_streaming && !s.active_stream_id && !s.pending_user_message && !s.has_pending_user_message && !s.pending_started_at); } function _reconcileActiveSessionIdleStateFromList(serverRows) { @@ -605,7 +609,9 @@ function _markPollingCompletionUnreadTransitions(sessions) { const observedStreaming = _getSessionObservedStreaming()[sid]; const messageCount = Number(s.message_count || 0); const lastMessageAt = Number(s.last_message_at || 0); - const completedObservedStream = wasStreaming === true && !isStreaming; + const hasServerRunSignal=Boolean(s.is_streaming||_hasPendingUserMessageSignal(s)); + const canMarkCompletedStream=Boolean(hasServerRunSignal||previousSnapshot||observedStreaming); + const completedObservedStream = canMarkCompletedStream&&wasStreaming === true && !isStreaming; const completedWithNewMessages = Boolean( (previousSnapshot || observedStreaming) && !isStreaming @@ -3278,8 +3284,11 @@ function _openSessionActionMenu(session, anchorEl){ try{ await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})}); session.pinned=newPinned; + const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id); + if(cached) cached.pinned=newPinned; if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned; - renderSessionList(); + renderSessionListFromCache(); + void renderSessionList(); }catch(err){ showToast(t('session_pin_failed')+err.message); await renderSessionList(); @@ -3606,7 +3615,7 @@ function _applySessionListPayload(sessData, projData){ _sessionListLoadError = null; _sessionListHasLoadedOnce = true; _markPollingCompletionUnreadTransitions(_allSessions); - const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming)); + const isStreaming = _allSessions.some(s => _isSessionEffectivelyStreaming(s)); if (isStreaming) { startStreamingPoll(); } else { @@ -4365,7 +4374,7 @@ function filterSessions(){ } function _sessionTimestampMs(session) { - const raw = Number(session && (session.last_message_at || session.updated_at || session.created_at || 0)); + const raw = Number(session && (session._sidebar_activity_at || session.last_message_at || session.updated_at || session.created_at || 0)); return Number.isFinite(raw) ? raw * 1000 : 0; } @@ -4741,6 +4750,8 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere delete row._child_session_streaming; delete row._child_session_has_unread; delete row._child_session_attention; + delete row._child_session_latest_at; + delete row._sidebar_activity_at; return row; }; const rows=(collapsedRows||[]) @@ -4755,6 +4766,14 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere const bubbleSidebarState=(parentRow, childRow)=>{ if(isChildStreaming(childRow)) parentRow._child_session_streaming=true; if(childHasUnread(childRow)) parentRow._child_session_has_unread=true; + const childActivityRaw=childRow + ? (childRow._sidebar_activity_at??childRow.last_message_at??childRow.updated_at??childRow.created_at??0) + : 0; + const childActivitySec=Number(childActivityRaw); + if(Number.isFinite(childActivitySec)&&childActivitySec>Number(parentRow._child_session_latest_at||0)){ + parentRow._child_session_latest_at=childActivitySec; + parentRow._sidebar_activity_at=Math.max(Number(parentRow.last_message_at||parentRow.updated_at||parentRow.created_at||0), childActivitySec); + } const childAttention=childRow&&childRow.attention&&typeof childRow.attention==='object'?childRow.attention:null; if(!childAttention||!childAttention.kind||!Number.isFinite(Number(childAttention.count))||Number(childAttention.count)<=0) return; const priorityFor=(kind)=>kind==='approval'?3:(kind==='clarify'?2:1); @@ -4810,12 +4829,21 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere return false; }; const orphans=[]; - const attachQueue=[...(rawSessions||[])].sort((a,b)=>attachDepthFor(a)-attachDepthFor(b)); + const renderableChildIds=new Set((rawSessions||[]).map(s=>s&&s.session_id).filter(Boolean)); + const attachQueueById=new Map(); + for(const candidate of [...(rawSessions||[]),...(referenceSessions||[])]){ + if(candidate&&candidate.session_id&&!attachQueueById.has(candidate.session_id)) attachQueueById.set(candidate.session_id,candidate); + } + const attachQueue=[...attachQueueById.values()].sort((a,b)=>attachDepthFor(a)-attachDepthFor(b)); for(const child of attachQueue){ + const childRenderable=!!(child&&child.session_id&&renderableChildIds.has(child.session_id)); + if(child&&child.session_id&&visibleBySid.has(child.session_id)) continue; const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned); - if(!_isChildSession(child)&&!isForkChild) continue; + const childLineageKey=child&&(child._lineage_root_id||child.lineage_root_id||child.parent_session_id); + const isHiddenLineageReferenceChild=!!(child&&child.archived&&child.parent_session_id&&childLineageKey&&!child.pinned&&!childRenderable); + if(!_isChildSession(child)&&!isForkChild&&!isHiddenLineageReferenceChild) continue; if(!isForkChild&&child._cross_surface_child_session){ - orphans.push({...child,_orphan_child_session:true}); + if(childRenderable) orphans.push({...child,_orphan_child_session:true}); continue; } const parentSid=child.parent_session_id; @@ -4829,22 +4857,27 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere if(!parentRow&&child._parent_lineage_root_id){ parentRow=visibleByLineageKey.get(child._parent_lineage_root_id)||null; } + if(!parentRow){ + parentRow=visibleByLineageKey.get(childLineageKey||parentSid)||null; + } if(!parentRow&&hasHiddenArchivedAncestor(child)){ hiddenArchivedChildTree.add(child.session_id); continue; } if(parentRow){ - if(!Array.isArray(parentRow._child_sessions)) parentRow._child_sessions=[]; const childCopy={...child}; if(parentSegment){ childCopy._parent_segment_id=parentSegment.session_id; childCopy._parent_segment_title=_sessionDisplayTitle(parentSegment)||child.parent_title||'Untitled'; } - parentRow._child_sessions.push(childCopy); - parentRow._child_session_count=parentRow._child_sessions.length; + if(childRenderable&&!isHiddenLineageReferenceChild){ + if(!Array.isArray(parentRow._child_sessions)) parentRow._child_sessions=[]; + parentRow._child_sessions.push(childCopy); + parentRow._child_session_count=parentRow._child_sessions.length; + } bubbleSidebarState(parentRow, childCopy); visibleBySegmentSid.set(childCopy.session_id,{row: parentRow, seg: childCopy}); - } else { + } else if(childRenderable) { orphans.push({...child,_orphan_child_session:true}); } } diff --git a/tests/test_issue2454_active_session_spinner.py b/tests/test_issue2454_active_session_spinner.py index b2c9ffa6..e9872996 100644 --- a/tests/test_issue2454_active_session_spinner.py +++ b/tests/test_issue2454_active_session_spinner.py @@ -43,6 +43,7 @@ def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state(): assert "!s.is_streaming" in helper_body, "server idle helper must require is_streaming=false" assert "!s.active_stream_id" in helper_body, "server idle helper must require no active stream id" assert "!s.pending_user_message" in helper_body, "server idle helper must require no pending user text" + assert "!s.pending_started_at" in helper_body, "server idle helper must require no pending turn marker" assert "S.busy=false" in body, "stale local busy state must be cleared" assert "S.activeStreamId=null" in body, "stale active stream id must be cleared" assert "delete INFLIGHT[sid]" in body, "stale active-session inflight cache must be purged" @@ -59,6 +60,41 @@ def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state(): ) +def test_effective_streaming_ignores_stale_raw_active_stream_ids(): + local_body = _function_body(SESSIONS_SRC, "function _isSessionLocallyStreaming(") + pending_body = _function_body(SESSIONS_SRC, "function _hasPendingUserMessageSignal(") + effective_body = _function_body(SESSIONS_SRC, "function _isSessionEffectivelyStreaming(") + + assert "s.active_stream_id" not in effective_body + assert "_hasPendingUserMessageSignal(s)" in effective_body + assert "s.pending_started_at" not in effective_body + + script = f""" +let S = {{ session: null, busy: false }}; +function _isSessionLocallyStreaming(s) {{{local_body}}} +function _hasPendingUserMessageSignal(s) {{{pending_body}}} +function _isSessionEffectivelyStreaming(s) {{{effective_body}}} +const rows = {{ + serverStreaming: _isSessionEffectivelyStreaming({{session_id:'child', is_streaming:true, active_stream_id:'stream-1'}}), + staleActiveStream: _isSessionEffectivelyStreaming({{session_id:'child', is_streaming:false, active_stream_id:'dead-stream'}}), + pendingUser: _isSessionEffectivelyStreaming({{session_id:'child', pending_user_message:'working'}}), + pendingFlag: _isSessionEffectivelyStreaming({{session_id:'child', has_pending_user_message:true}}), + pendingStartedOnly: _isSessionEffectivelyStreaming({{session_id:'child', pending_started_at:123}}), + idle: _isSessionEffectivelyStreaming({{session_id:'child'}}), +}}; +console.log(JSON.stringify(rows)); +""" + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + assert json.loads(result.stdout) == { + "serverStreaming": True, + "staleActiveStream": False, + "pendingUser": True, + "pendingFlag": True, + "pendingStartedOnly": False, + "idle": False, + } + + def test_active_session_idle_reconcile_schedules_forced_transcript_reload(): helper_body = _function_body(SESSIONS_SRC, "function _scheduleActiveSessionIdleReload(") diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index e23bf79d..35c07d5b 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -1,5 +1,6 @@ """Regression checks for #856 background completion unread markers.""" +import json from pathlib import Path @@ -24,6 +25,21 @@ def _sessions_function_block(name: str, next_name: str) -> str: return SESSIONS_JS[start:end] +def _function_body(block: str) -> str: + brace = block.find("{") + assert brace != -1, "function opening brace not found" + depth = 0 + for i in range(brace, len(block)): + ch = block[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return block[brace + 1 : i] + raise AssertionError("function closing brace not found") + + def test_background_completion_unread_uses_explicit_marker_not_message_delta(): """A background completion must stay unread even when message_count has no delta.""" assert "SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread'" in SESSIONS_JS @@ -117,10 +133,10 @@ def test_polling_transition_marks_completion_unread_without_sse_done(): "_markPollingCompletionUnreadTransitions", "newSession", ) - effective_block = _sessions_function_block( + effective_block = _function_body(_sessions_function_block( "_isSessionEffectivelyStreaming", "_markPollingCompletionUnreadTransitions", - ) + )) render_idx = SESSIONS_JS.find("async function renderSessionList") assert render_idx != -1, "renderSessionList not found" refresh_idx = SESSIONS_JS.find("async function _runRenderSessionListRefresh") @@ -134,7 +150,11 @@ def test_polling_transition_marks_completion_unread_without_sse_done(): assert "const _sessionStreamingById = new Map();" in SESSIONS_JS assert "const wasStreaming = _sessionStreamingById.get(sid);" in transition_block assert "const isStreaming = _isSessionEffectivelyStreaming(s);" in transition_block - assert "s.is_streaming || _isSessionLocallyStreaming(s)" in effective_block + assert "s.is_streaming" in effective_block + assert "s.active_stream_id" not in effective_block + assert "_hasPendingUserMessageSignal(s)" in effective_block + assert "s.pending_started_at" not in effective_block + assert "_isSessionLocallyStreaming(s)" in effective_block assert "wasStreaming === true && !isStreaming" in transition_block, ( "polling fallback must only fire on an observed streaming -> stopped transition" ) @@ -142,6 +162,102 @@ def test_polling_transition_marks_completion_unread_without_sse_done(): assert "_sessionStreamingById.set(sid, isStreaming);" in transition_block assert "_applySessionListPayload(sessData,projData);" in refresh_block assert "_markPollingCompletionUnreadTransitions(_allSessions);" in apply_block + assert "_allSessions.some(s => _isSessionEffectivelyStreaming(s))" in apply_block, ( + "the streaming poll fallback must stay active for the same server-confirmed " + "streaming states that can render a sidebar spinner" + ) + + +def test_polling_transition_ignores_stale_active_stream_id_without_server_streaming(): + local_body = _function_body(_sessions_function_block( + "_isSessionLocallyStreaming", + "_isSessionEffectivelyStreaming", + )) + effective_body = _function_body(_sessions_function_block( + "_isSessionEffectivelyStreaming", + "_markPollingCompletionUnreadTransitions", + )) + transition_body = _function_body(_sessions_function_block( + "_markPollingCompletionUnreadTransitions", + "newSession", + )) + + script = f""" +let S = {{ session: null, busy: false }}; +const unread = []; +const _sessionStreamingById = new Map([['stale', true]]); +const _sessionListSnapshotById = new Map(); +function _isSessionLocallyStreaming(s) {{{local_body}}} +function _hasPendingUserMessageSignal(s) {{ return !!(s && (s.pending_user_message || s.has_pending_user_message)); }} +function _isSessionEffectivelyStreaming(s) {{{effective_body}}} +function _hasSessionCompletionUnread() {{ return false; }} +function _markSessionCompletionUnread(sid) {{ unread.push(sid); }} +function _isSessionActivelyViewedForList() {{ return false; }} +function _rememberObservedStreamingSession() {{}} +function _forgetObservedStreamingSession() {{}} +function _getSessionObservedStreaming() {{ return {{}}; }} +function _markPollingCompletionUnreadTransitions(sessions) {{{transition_body}}} +_markPollingCompletionUnreadTransitions([{{ + session_id:'stale', + is_streaming:false, + active_stream_id:'dead-stream', + message_count:5, + last_message_at:10, + updated_at:10, +}}]); +console.log(JSON.stringify({{unread, observed:_sessionStreamingById.get('stale')}})); +""" + import subprocess + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + assert json.loads(result.stdout) == {"unread": [], "observed": False} + + +# The observed-streaming marker is persisted across reloads, unlike in-memory snapshots. +def test_polling_transition_marks_persisted_observed_stream_after_reload(): + local_body = _function_body(_sessions_function_block( + "_isSessionLocallyStreaming", + "_isSessionEffectivelyStreaming", + )) + effective_body = _function_body(_sessions_function_block( + "_isSessionEffectivelyStreaming", + "_markPollingCompletionUnreadTransitions", + )) + transition_body = _function_body(_sessions_function_block( + "_markPollingCompletionUnreadTransitions", + "newSession", + )) + + script = f""" +let S = {{ session: null, busy: false }}; +const unread = []; +const _sessionStreamingById = new Map(); +const _sessionListSnapshotById = new Map(); +function _isSessionLocallyStreaming(s) {{{local_body}}} +function _hasPendingUserMessageSignal(s) {{ return !!(s && (s.pending_user_message || s.has_pending_user_message)); }} +function _isSessionEffectivelyStreaming(s) {{{effective_body}}} +function _hasSessionCompletionUnread() {{ return false; }} +function _markSessionCompletionUnread(sid) {{ unread.push(sid); }} +function _isSessionActivelyViewedForList() {{ return false; }} +let observed = {{ + done: {{message_count: 5, last_message_at: 10}} +}}; +function _rememberObservedStreamingSession() {{}} +function _forgetObservedStreamingSession(sid) {{ delete observed[sid]; }} +function _getSessionObservedStreaming() {{ return observed; }} +function _markPollingCompletionUnreadTransitions(sessions) {{{transition_body}}} +_markPollingCompletionUnreadTransitions([{{ + session_id:'done', + is_streaming:false, + active_stream_id:null, + message_count:5, + last_message_at:10, + updated_at:10, +}}]); +console.log(JSON.stringify({{unread, observed, streaming:_sessionStreamingById.get('done')}})); +""" + import subprocess + result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + assert json.loads(result.stdout) == {"unread": ["done"], "observed": {}, "streaming": False} def test_polling_transition_does_not_mark_historical_first_render(): @@ -187,17 +303,21 @@ def test_polling_transition_tracks_the_same_effective_streaming_state_as_sidebar "_isSessionLocallyStreaming", "_isSessionEffectivelyStreaming", ) - effective_block = _sessions_function_block( + effective_block = _function_body(_sessions_function_block( "_isSessionEffectivelyStreaming", "_markPollingCompletionUnreadTransitions", - ) + )) render_idx = SESSIONS_JS.find("function _renderOneSession") assert render_idx != -1, "_renderOneSession not found" render_block = SESSIONS_JS[render_idx:SESSIONS_JS.find("const hasUnread=", render_idx)] assert "isActive && Boolean(S.busy)" in local_block assert "INFLIGHT && INFLIGHT[s.session_id]" not in local_block - assert "s.is_streaming || _isSessionLocallyStreaming(s)" in effective_block + assert "s.is_streaming" in effective_block + assert "s.active_stream_id" not in effective_block + assert "_hasPendingUserMessageSignal(s)" in effective_block + assert "s.pending_started_at" not in effective_block + assert "_isSessionLocallyStreaming(s)" in effective_block assert "const ownStreaming=_isSessionEffectivelyStreaming(s)" in render_block, ( "the row spinner and polling completion transition must use the same " "effective streaming source, including local INFLIGHT-only streams" diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index f1a7712d..9d441245 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -730,7 +730,7 @@ console.log(JSON.stringify(rows)); assert "_child_sessions" not in rows[0] -def test_nested_fork_keeps_parent_timestamp_for_sorting(): +def test_nested_fork_bubbles_latest_child_timestamp_for_sorting(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f""" const src = {js!r}; @@ -755,11 +755,313 @@ eval(extractFunc('_attachChildSessionsToSidebarRows')); const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}}; const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', updated_at:20, last_message_at:20}}; const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]); +console.log(JSON.stringify({{row: rows[0], timestampMs: _sessionTimestampMs(rows[0])}})); +""" + result = json.loads(_run_node(source)) + row = result["row"] + assert row["session_id"] == "parent" + # Keep the parent row's persisted timestamp intact, but use newest attached + # child/subagent activity for sidebar sorting/date grouping. + assert row["last_message_at"] == 10 + assert row["_child_session_latest_at"] == 20 + assert row["_sidebar_activity_at"] == 20 + assert result["timestampMs"] == 20_000 + + +def test_hidden_archived_lineage_child_bubbles_running_state_and_activity_to_visible_parent(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function _isSessionEffectivelyStreaming(session) {{ + return !!(session && (session.is_streaming || session.active_stream_id)); +}} +function _hasUnreadForSession(session) {{ + return !!(session && session.has_unread); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +const visibleTip = {{ + session_id:'visible-tip', + title:'Visible lineage row', + parent_session_id:'parent-root', + session_source:'webui', + _lineage_root_id:'parent-root', + updated_at:10, + last_message_at:10, +}}; +const archivedTip = {{ + session_id:'archived-tip', + title:'Archived running tip', + parent_session_id:'parent-root', + session_source:'webui', + archived:true, + _lineage_root_id:'parent-root', + is_streaming:true, + active_stream_id:'stream-1', + updated_at:30, + last_message_at:30, +}}; +const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]); +console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}})); +""" + result = json.loads(_run_node(source)) + row = result["row"] + assert result["count"] == 1 + assert row["session_id"] == "visible-tip" + assert "_child_sessions" not in row + assert row["_child_session_streaming"] is True + assert row["_child_session_latest_at"] == 30 + assert row["_sidebar_activity_at"] == 30 + assert result["timestampMs"] == 30_000 + + +def test_archived_lineage_child_without_root_id_falls_back_to_parent_for_bubbling(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function _isSessionEffectivelyStreaming(session) {{ + return !!(session && (session.is_streaming || session.active_stream_id)); +}} +function _hasUnreadForSession(session) {{ + return !!(session && session.has_unread); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +const visibleTip = {{ + session_id:'visible-tip', + title:'Visible lineage row', + parent_session_id:'parent-root', + session_source:'webui', + _lineage_root_id:'parent-root', + updated_at:10, + last_message_at:10, +}}; +const archivedTip = {{ + session_id:'archived-tip', + title:'Archived running tip', + parent_session_id:'parent-root', + session_source:'webui', + archived:true, + is_streaming:true, + active_stream_id:'stream-1', + updated_at:30, + last_message_at:30, +}}; +const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]); +console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}})); +""" + result = json.loads(_run_node(source)) + row = result["row"] + assert result["count"] == 1 + assert row["session_id"] == "visible-tip" + assert "_child_sessions" not in row + assert row["_child_session_streaming"] is True + assert row["_child_session_latest_at"] == 30 + assert row["_sidebar_activity_at"] == 30 + assert result["timestampMs"] == 30_000 + + +def test_non_archived_reference_only_lineage_row_does_not_bubble_to_visible_parent(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function _isSessionEffectivelyStreaming(session) {{ + return !!(session && (session.is_streaming || session.active_stream_id)); +}} +function _hasUnreadForSession(session) {{ + return !!(session && session.has_unread); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +const visibleTip = {{ + session_id:'visible-tip', + title:'Visible lineage row', + parent_session_id:'parent-root', + session_source:'webui', + _lineage_root_id:'parent-root', + updated_at:10, + last_message_at:10, +}}; +const filteredReferenceOnly = {{ + session_id:'filtered-tip', + title:'Filtered non-archived tip', + parent_session_id:'parent-root', + session_source:'webui', + _lineage_root_id:'parent-root', + archived:false, + is_streaming:true, + active_stream_id:'stream-1', + updated_at:30, + last_message_at:30, +}}; +const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, filteredReferenceOnly]); +console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}})); +""" + result = json.loads(_run_node(source)) + row = result["row"] + assert result["count"] == 1 + assert row["session_id"] == "visible-tip" + assert "_child_sessions" not in row + assert "_child_session_streaming" not in row + assert "_child_session_latest_at" not in row + assert "_sidebar_activity_at" not in row + assert result["timestampMs"] == 10_000 + + +def test_stale_active_stream_id_on_hidden_archived_child_does_not_bubble_streaming_state(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function _isSessionEffectivelyStreaming(session) {{ + return !!(session && (session.is_streaming || session.pending_user_message || session.has_pending_user_message)); +}} +function _hasUnreadForSession(session) {{ + return !!(session && session.has_unread); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +const visibleTip = {{ + session_id:'visible-tip', + title:'Visible lineage row', + parent_session_id:'parent-root', + session_source:'webui', + _lineage_root_id:'parent-root', + updated_at:10, + last_message_at:10, +}}; +const archivedTip = {{ + session_id:'archived-tip', + title:'Archived stale tip', + parent_session_id:'parent-root', + session_source:'webui', + archived:true, + _lineage_root_id:'parent-root', + is_streaming:false, + active_stream_id:'dead-stream', + updated_at:30, + last_message_at:30, +}}; +const rows = _attachChildSessionsToSidebarRows([visibleTip], [visibleTip], [visibleTip, archivedTip]); +console.log(JSON.stringify({{row: rows[0], count: rows.length, timestampMs: _sessionTimestampMs(rows[0])}})); +""" + result = json.loads(_run_node(source)) + row = result["row"] + assert result["count"] == 1 + assert row["session_id"] == "visible-tip" + assert "_child_sessions" not in row + assert "_child_session_streaming" not in row + assert row["_child_session_latest_at"] == 30 + assert row["_sidebar_activity_at"] == 30 + assert result["timestampMs"] == 30_000 + + +def test_collapsed_lineage_segments_do_not_render_as_child_sessions(): + js = SESSIONS_JS_PATH.read_text(encoding="utf-8") + source = f""" +const src = {js!r}; +function extractFunc(name) {{ + const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') depth--; + i++; + }} + return src.slice(start, i); +}} +function _isSessionEffectivelyStreaming(session) {{ + return !!(session && (session.is_streaming || session.active_stream_id)); +}} +function _hasUnreadForSession(session) {{ + return !!(session && session.has_unread); +}} +eval(extractFunc('_sessionTimestampMs')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sessionLineageKey')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_collapseSessionLineageForSidebar')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +eval(extractFunc('_renderSidebarRowsFromRawSessions')); +const root = {{session_id:'root', title:'Root', updated_at:10, last_message_at:10, _lineage_root_id:'root', _lineage_tip_id:'root'}}; +const mid = {{session_id:'mid', title:'Middle', parent_session_id:'root', updated_at:20, last_message_at:20, _lineage_root_id:'root', _lineage_tip_id:'mid'}}; +const tip = {{session_id:'tip', title:'Tip', parent_session_id:'root', updated_at:30, last_message_at:30, _lineage_root_id:'root', _lineage_tip_id:'tip'}}; +const rows = _renderSidebarRowsFromRawSessions([root, mid, tip], [root, mid, tip]); console.log(JSON.stringify(rows)); """ rows = json.loads(_run_node(source)) - assert rows[0]["session_id"] == "parent" - assert rows[0]["last_message_at"] == 10 + assert [row["session_id"] for row in rows] == ["tip"] + assert rows[0]["_lineage_collapsed_count"] == 3 + assert [seg["session_id"] for seg in rows[0]["_lineage_segments"]] == ["tip", "mid", "root"] + assert "_child_sessions" not in rows[0] + assert "_child_session_count" not in rows[0] def test_nested_fork_bubbles_parent_attention_state(): diff --git a/tests/test_sidebar_pin_immediate_reorder.py b/tests/test_sidebar_pin_immediate_reorder.py new file mode 100644 index 00000000..1497fdd9 --- /dev/null +++ b/tests/test_sidebar_pin_immediate_reorder.py @@ -0,0 +1,25 @@ +"""Regression coverage for immediate sidebar pin/unpin reordering.""" +from pathlib import Path + + +SESSIONS_JS = (Path(__file__).resolve().parents[1] / "static" / "sessions.js").read_text(encoding="utf-8") + + +def test_pin_action_updates_local_cache_and_renders_before_refetch(): + """Pin/unpin should move rows immediately, not only after a browser refresh.""" + api_idx = SESSIONS_JS.find("await api('/api/session/pin'") + assert api_idx != -1, "pin action API call not found" + snippet = SESSIONS_JS[api_idx:api_idx + 700] + + cached_idx = snippet.find("const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);") + update_idx = snippet.find("if(cached) cached.pinned=newPinned;") + active_idx = snippet.find("if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;") + cache_render_idx = snippet.find("renderSessionListFromCache();") + refetch_idx = snippet.find("void renderSessionList();") + + assert cached_idx != -1, "pin action must find the cached sidebar row" + assert update_idx != -1, "pin action must update cached row.pinned" + assert active_idx != -1, "pin action must update the active session if pinned" + assert cache_render_idx != -1, "pin action must render from cache immediately" + assert refetch_idx != -1, "pin action should still reconcile with the server" + assert cached_idx < update_idx < active_idx < cache_render_idx < refetch_idx From 4f071895ccf150adc58bff0e14aafaf40450192b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 02:08:59 +0000 Subject: [PATCH 02/45] perf(#4662): read redaction setting once per sidebar response, not per row Phase 3 of #4662. _session_list_payload_to_response read settings.json once per row via _redact_text's per-call load_settings() fallback (the multi-second response_write stage in /api/sessions diagnostics on large lists). The _sidebar_session_response_item -> _redact_text(_enabled=...) threading already existed; this wires the caller to read the api_redact_enabled flag once and thread it through every row, matching the read-once optimization redact_session_data() already uses. Behavior-preserving; redaction still applies (or not) exactly as before. --- api/routes.py | 13 +++- ...t_issue4662_sidebar_redaction_read_once.py | 67 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue4662_sidebar_redaction_read_once.py diff --git a/api/routes.py b/api/routes.py index 25796f0b..165ac167 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2125,8 +2125,19 @@ def _build_session_list_cache_payload( def _session_list_payload_to_response(payload: dict) -> dict: safe_merged = [] runtime_rows = _session_list_cache_overlay_runtime_rows(payload.get("sessions", []) or []) + # Read the redaction setting ONCE for the whole response and thread it through + # every row, instead of letting each row's _redact_text() re-read settings.json + # from disk (per title). The _sidebar_session_response_item -> _redact_text(_enabled=...) + # plumbing already exists; this wires the caller so the sidebar list path gets the + # same read-once optimization redact_session_data() already uses. On a large list + # this was the multi-second response_write stage in /api/sessions diagnostics. (#4662 Phase 3) + try: + from api.config import load_settings + _redact_enabled = bool(load_settings().get("api_redact_enabled", True)) + except Exception: + _redact_enabled = True # fail safe: redact when settings are unreadable for s in runtime_rows: - item = _sidebar_session_response_item(s) if isinstance(s, dict) else {} + item = _sidebar_session_response_item(s, redact_enabled=_redact_enabled) if isinstance(s, dict) else {} safe_merged.append(item) return { "sessions": safe_merged, diff --git a/tests/test_issue4662_sidebar_redaction_read_once.py b/tests/test_issue4662_sidebar_redaction_read_once.py new file mode 100644 index 00000000..5951fed2 --- /dev/null +++ b/tests/test_issue4662_sidebar_redaction_read_once.py @@ -0,0 +1,67 @@ +"""Phase 3 (#4662): sidebar session-list serialization must read the redaction +setting ONCE per response, not once per row. Regression guard for the per-row +settings.json reload that dominated /api/sessions response_write on large lists. +""" +import api.routes as routes + + +def test_sidebar_payload_reads_redaction_setting_once(monkeypatch): + calls = {"n": 0} + + def _counting_load_settings(): + calls["n"] += 1 + return {"api_redact_enabled": True} + + # _redact_text() falls back to load_settings() only when _enabled is None, + # so a per-row read shows up as a load_settings() call per row. After the + # fix the caller reads it once and threads redact_enabled to every row. + monkeypatch.setattr("api.config.load_settings", _counting_load_settings) + monkeypatch.setattr("api.helpers.load_settings", _counting_load_settings, raising=False) + + payload = { + "sessions": [ + {"session_id": f"s{i}", "title": f"title {i}", "preview": f"preview {i}"} + for i in range(12) + ], + "cli_count": 0, + } + # Pass rows straight through — avoid runtime-overlay noise from the cache layer. + monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) + + routes._session_list_payload_to_response(payload) + + # Before the fix: ~1 read per row (>=12). After: exactly 1 for the whole response. + assert calls["n"] <= 1, f"settings read {calls['n']}x; expected <=1 (read-once per response)" + + +def test_sidebar_payload_still_redacts_titles(monkeypatch): + """The read-once optimization must not disable redaction: a title that looks + like a credential is still redacted when api_redact_enabled is True.""" + monkeypatch.setattr("api.config.load_settings", lambda: {"api_redact_enabled": True}) + monkeypatch.setattr("api.helpers.load_settings", lambda: {"api_redact_enabled": True}, raising=False) + monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) + + secret = "sk-ant-api03-" + ("A" * 40) + payload = {"sessions": [{"session_id": "s1", "title": f"key {secret}"}], "cli_count": 0} + resp = routes._session_list_payload_to_response(payload) + title = resp["sessions"][0]["title"] + assert secret not in title, f"credential leaked into sidebar title: {title!r}" + + +def test_sidebar_payload_no_redaction_when_disabled(monkeypatch): + """When api_redact_enabled is False, titles pass through unchanged (and we + still only read the setting once).""" + calls = {"n": 0} + + def _load(): + calls["n"] += 1 + return {"api_redact_enabled": False} + + monkeypatch.setattr("api.config.load_settings", _load) + monkeypatch.setattr("api.helpers.load_settings", _load, raising=False) + monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) + + payload = {"sessions": [{"session_id": "s1", "title": "plain title"}], "cli_count": 0} + resp = routes._session_list_payload_to_response(payload) + assert resp["sessions"][0]["title"] == "plain title" + assert calls["n"] <= 1, f"settings read {calls['n']}x with redaction disabled; expected <=1" From 8006db22fc79aaabbb44b552032b49d9df9eca3e Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 02:13:33 +0000 Subject: [PATCH 03/45] perf(#4662): route reload_config parse through the mtime config cache Phase 2 of #4662. reload_config() did a raw yaml.safe_load of config.yaml on every call (~125ms+ on a large file), including on the profile-switch hot path (~446ms load_settings stage in /api/sessions diagnostics). It now parses through the mtime+size-keyed cache added in #4652, via a shared _load_yaml_config_file_raw core (DRY: _load_yaml_config_file = raw + env-expand). reload_config still runs its OWN env expansion pinned to the unscoped process-env view (block_process_env_fallback reset + _thread_ctx.env={}) so the #798 TLS profile-isolation invariant is byte-for-byte preserved; only the disk parse is now cached. An unchanged config.yaml is no longer reparsed; an mtime change still busts the cache. --- api/config.py | 34 ++++++-- ...est_issue4662_reload_config_mtime_cache.py | 78 +++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 tests/test_issue4662_reload_config_mtime_cache.py diff --git a/api/config.py b/api/config.py index fc42f035..58aa3e8b 100644 --- a/api/config.py +++ b/api/config.py @@ -485,11 +485,16 @@ def reload_config() -> None: _cfg_path = config_path _cfg_mtime = 0.0 try: - import yaml as _yaml - if config_path.exists(): - loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) - if isinstance(loaded, dict): + # Route the parse through the mtime-keyed cache (#4652) so an + # unchanged config.yaml isn't re-parsed (~125ms+ on a large file) + # on every reload_config() on the hot path (profile switch / + # load_settings, #4662 Phase 2). We take the RAW cached dict and + # run the env expansion HERE, pinned to the unscoped process-env + # view (below) — never the helper's per-call expansion — for the + # #798 TLS reason documented in the pin block. + loaded = _load_yaml_config_file_raw(config_path) + if isinstance(loaded, dict) and loaded: # The process-global _cfg_cache must reflect PROCESS-env # expansion, never a profile-scoped block_process_env_fallback # view — otherwise a reload that fires while a readonly/worker @@ -544,7 +549,17 @@ _yaml_file_cache: dict[str, tuple] = {} _yaml_file_cache_lock = threading.Lock() -def _load_yaml_config_file(config_path: Path) -> dict: +def _load_yaml_config_file_raw(config_path: Path) -> dict: + """Return the RAW (un-env-expanded) parsed config dict, memoized on + (resolved path, st_mtime_ns, st_size). Shared parse core for + _load_yaml_config_file() and reload_config(): the former runs the helper's + own per-call env expansion on the result; the latter must run expansion + under its own process-env-pinned thread context (#798), so it takes the raw + dict and expands it itself. Either way the file is parsed at most once per + (mtime, size) — a UI sync storm can't turn into a YAML-reparse storm (#4650), + and an unchanged config.yaml isn't reparsed on the profile-switch hot path + (#4662 Phase 2). + """ try: import yaml as _yaml except ImportError: @@ -561,8 +576,8 @@ def _load_yaml_config_file(config_path: Path) -> dict: with _yaml_file_cache_lock: cached = _yaml_file_cache.get(cache_key) if cached is not None and cached[0] == stat_key: - expanded = _expand_env_vars(cached[1]) - return expanded if isinstance(expanded, dict) else {} + raw = cached[1] + return raw if isinstance(raw, dict) else {} # Cache miss / stale: parse off disk. Done outside the lock so a slow parse # doesn't serialize unrelated paths; a concurrent duplicate parse is harmless. @@ -575,6 +590,11 @@ def _load_yaml_config_file(config_path: Path) -> dict: raw = loaded if isinstance(loaded, dict) else {} with _yaml_file_cache_lock: _yaml_file_cache[cache_key] = (stat_key, raw) + return raw + + +def _load_yaml_config_file(config_path: Path) -> dict: + raw = _load_yaml_config_file_raw(config_path) if not raw: return {} expanded = _expand_env_vars(raw) diff --git a/tests/test_issue4662_reload_config_mtime_cache.py b/tests/test_issue4662_reload_config_mtime_cache.py new file mode 100644 index 00000000..4ff67f57 --- /dev/null +++ b/tests/test_issue4662_reload_config_mtime_cache.py @@ -0,0 +1,78 @@ +"""Phase 2 (#4662): reload_config() must not re-run yaml.safe_load on the hot path +when config.yaml is unchanged. It now routes its parse through the mtime-keyed +_load_yaml_config_file cache (#4652). Behavior-preserving: the process-global +_cfg_cache is still pinned to the unscoped process-env expansion (#798 TLS), env +expansion still runs per call, and an mtime change still busts the cache. +""" +import os +import time + +import yaml as _yaml + + +def test_reload_config_uses_mtime_cache(tmp_path, monkeypatch): + import api.config as cfg + + config_path = tmp_path / "config.yaml" + config_path.write_text("providers:\n openai:\n models: [gpt-5.5]\n", encoding="utf-8") + monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path) + + parse_calls = {"n": 0} + real_safe_load = _yaml.safe_load + + def _counting_safe_load(s): + parse_calls["n"] += 1 + return real_safe_load(s) + + monkeypatch.setattr(_yaml, "safe_load", _counting_safe_load) + # Clear the shared file cache so the first reload is a genuine miss. + with cfg._yaml_file_cache_lock: + cfg._yaml_file_cache.clear() + + cfg.reload_config() + first = parse_calls["n"] + cfg.reload_config() # same file, unchanged mtime -> must hit cache + second = parse_calls["n"] + + assert first >= 1, "first reload should parse the file at least once" + assert second == first, f"unchanged config.yaml was reparsed (parse went {first}->{second})" + + +def test_reload_config_busts_on_mtime_change(tmp_path, monkeypatch): + import api.config as cfg + + config_path = tmp_path / "config.yaml" + config_path.write_text("providers: {}\n", encoding="utf-8") + monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path) + with cfg._yaml_file_cache_lock: + cfg._yaml_file_cache.clear() + + cfg.reload_config() + assert (cfg.get_config().get("providers") or {}) == {} + + # Edit + bump mtime; the next reload must pick up the change, not the cache. + time.sleep(0.01) + config_path.write_text("providers:\n openai: {}\n", encoding="utf-8") + os.utime(config_path, None) + cfg.reload_config() + assert "openai" in (cfg.get_config().get("providers") or {}), "mtime change not picked up" + + +def test_reload_config_expands_env_vars(tmp_path, monkeypatch): + """The pinned process-env expansion must still run: a ${VAR} in config.yaml + resolves against os.environ after reload_config (the #798 invariant).""" + import api.config as cfg + + monkeypatch.setenv("HERMES_TEST_RELOAD_TOKEN", "expanded-value-xyz") + config_path = tmp_path / "config.yaml" + config_path.write_text( + "providers:\n openai:\n api_key: ${HERMES_TEST_RELOAD_TOKEN}\n", + encoding="utf-8", + ) + monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path) + with cfg._yaml_file_cache_lock: + cfg._yaml_file_cache.clear() + + cfg.reload_config() + key = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key") + assert key == "expanded-value-xyz", f"env var not expanded after reload: {key!r}" From 6ddd519ba9628a9c2e3f6f83424ca4af0c7de0f8 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 02:13:51 +0000 Subject: [PATCH 04/45] docs(#4662): CHANGELOG entry for phase 2+3 backend caching wins --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 765f626f..276a8baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Changed + +- **Faster profile switching: the backend stops re-reading config and settings on the hot path.** Two behavior-preserving caching wins on the `/api/sessions` path that a profile switch hits cold (#4662, phase 2+3). (1) `reload_config()` no longer re-parses `config.yaml` from disk when the file is unchanged — it now routes through the same mtime-keyed parse cache used elsewhere, removing a ~hundreds-of-ms YAML reparse from each switch while preserving the exact process-env-expansion semantics that keep per-client profile isolation correct. (2) The sidebar session-list response now reads the redaction setting once per response instead of once per conversation row, eliminating a per-row `settings.json` read that grew into a multi-second serialization stage on large session lists. No user-visible behavior change — redaction still applies exactly as before; switching is just quicker. Part of the phased profile-switch performance work. (#4662) + ## [v0.51.599] — 2026-06-23 — Release VF (dedupe live progress reasoning echoes) ### Fixed From 001ee0a9864d3d07e521cb67ebeece64f60b4eeb Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 02:52:05 +0000 Subject: [PATCH 05/45] fix(#4662): stamp _cfg_mtime for empty-dict config (Opus gate finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'and loaded' guard skipped the _cfg_mtime stamp for a config that parses to an empty {} dict, so get_config()'s current_mtime != _cfg_mtime stale check fired on every call and re-entered reload_config() under _cfg_lock forever — a hot-path spin for a freshly created/reset profile's {} config. Split the guard: the no-op _cfg_cache.update is still skipped for {}, but _cfg_mtime is always stamped when the file parsed to a dict, matching master's pre-#4662 behavior. Incidentally fixes the pre-existing empty/None-config spin. Adds a regression test. --- api/config.py | 58 +++++++++++-------- ...est_issue4662_reload_config_mtime_cache.py | 40 +++++++++++++ 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/api/config.py b/api/config.py index 58aa3e8b..32ebc5ce 100644 --- a/api/config.py +++ b/api/config.py @@ -494,30 +494,40 @@ def reload_config() -> None: # view (below) — never the helper's per-call expansion — for the # #798 TLS reason documented in the pin block. loaded = _load_yaml_config_file_raw(config_path) - if isinstance(loaded, dict) and loaded: - # The process-global _cfg_cache must reflect PROCESS-env - # expansion, never a profile-scoped block_process_env_fallback - # view — otherwise a reload that fires while a readonly/worker - # scope is active (profile alternation resolves _get_config_path - # to the named profile, #798 TLS) would bake under-expanded - # literal ${VAR}s into the shared cache and starve concurrent - # readers of the module-level `cfg` alias. Expansion re-runs - # per-read elsewhere; here we pin the cache to the unscoped view. - _prev_block = getattr(_thread_ctx, "block_process_env_fallback", False) - _prev_env = getattr(_thread_ctx, "env", None) - try: - _thread_ctx.block_process_env_fallback = False - _thread_ctx.env = {} - _cfg_cache.update(_expand_env_vars(loaded)) - finally: - _thread_ctx.block_process_env_fallback = _prev_block - if _prev_env is None: - try: - del _thread_ctx.env - except AttributeError: - pass - else: - _thread_ctx.env = _prev_env + if isinstance(loaded, dict): + if loaded: + # The process-global _cfg_cache must reflect PROCESS-env + # expansion, never a profile-scoped block_process_env_fallback + # view — otherwise a reload that fires while a readonly/worker + # scope is active (profile alternation resolves _get_config_path + # to the named profile, #798 TLS) would bake under-expanded + # literal ${VAR}s into the shared cache and starve concurrent + # readers of the module-level `cfg` alias. Expansion re-runs + # per-read elsewhere; here we pin the cache to the unscoped view. + _prev_block = getattr(_thread_ctx, "block_process_env_fallback", False) + _prev_env = getattr(_thread_ctx, "env", None) + try: + _thread_ctx.block_process_env_fallback = False + _thread_ctx.env = {} + _cfg_cache.update(_expand_env_vars(loaded)) + finally: + _thread_ctx.block_process_env_fallback = _prev_block + if _prev_env is None: + try: + del _thread_ctx.env + except AttributeError: + pass + else: + _thread_ctx.env = _prev_env + # Stamp _cfg_mtime whenever the file parsed to a dict — INCLUDING + # an empty {} config. The cache-update above is skipped for {} (it's + # a no-op), but _cfg_mtime MUST still be set or get_config()'s + # `current_mtime != _cfg_mtime` stale check fires on every call and + # spins reload_config() under _cfg_lock forever (a `{}` config from a + # freshly created/reset profile is reachable on the switch hot path). + # This matches master's pre-#4662 behavior (it entered the block for + # {} and set the mtime); the inner `if loaded:` only gates the no-op + # cache update, not the mtime stamp. try: _cfg_mtime = Path(config_path).stat().st_mtime except OSError: diff --git a/tests/test_issue4662_reload_config_mtime_cache.py b/tests/test_issue4662_reload_config_mtime_cache.py index 4ff67f57..875f80c8 100644 --- a/tests/test_issue4662_reload_config_mtime_cache.py +++ b/tests/test_issue4662_reload_config_mtime_cache.py @@ -76,3 +76,43 @@ def test_reload_config_expands_env_vars(tmp_path, monkeypatch): cfg.reload_config() key = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key") assert key == "expanded-value-xyz", f"env var not expanded after reload: {key!r}" + + +def test_reload_config_empty_dict_config_does_not_spin(tmp_path, monkeypatch): + """An empty ``{}`` config must still stamp _cfg_mtime, or get_config()'s + `current_mtime != _cfg_mtime` stale check fires forever and re-enters + reload_config() under _cfg_lock on every call. The cache-update is correctly + skipped for {} (no-op), but the mtime stamp must not be. (Opus gate finding — + a {} config is reachable on the profile-switch hot path via a freshly + created/reset profile, and this also fixes the pre-existing empty/None-config + spin on master.) + """ + import api.config as cfg + + config_path = tmp_path / "config.yaml" + config_path.write_text("{}\n", encoding="utf-8") + monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path) + with cfg._yaml_file_cache_lock: + cfg._yaml_file_cache.clear() + + cfg.reload_config() + # _cfg_mtime must equal the file's real mtime, not 0.0. + assert cfg._cfg_mtime == config_path.stat().st_mtime, ( + f"_cfg_mtime not stamped for empty-dict config (got {cfg._cfg_mtime!r}); " + "get_config() would spin reload_config() forever" + ) + + # And get_config() must NOT re-enter reload_config() on subsequent calls. + reloads = {"n": 0} + real_reload = cfg.reload_config + + def _counting_reload(): + reloads["n"] += 1 + return real_reload() + + monkeypatch.setattr(cfg, "reload_config", _counting_reload) + cfg.get_config() + cfg.get_config() + cfg.get_config() + assert reloads["n"] == 0, f"get_config() re-entered reload_config() {reloads['n']}x on a stable {{}} config (spin)" + From 2f250a419851477449b55eab9e29a50392d822c3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 05:23:21 +0000 Subject: [PATCH 06/45] =?UTF-8?q?review(#4741):=20address=20greptile=20P2s?= =?UTF-8?q?=20=E2=80=94=20drop=20redundant=20lazy=20import,=20deep-copy=20?= =?UTF-8?q?raw=20config=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two greptile findings on #4741: - api/routes.py: removed the redundant 'from api.config import load_settings' inside _session_list_payload_to_response — load_settings is already imported at module scope, and this function only runs at request time (after module load). - api/config.py: _load_yaml_config_file_raw now deep-copies the cached raw dict by default so a caller can never mutate the shared _yaml_file_cache entry. The internal _load_yaml_config_file passes _copy=False (it immediately runs _expand_env_vars, which returns a fresh structure and never mutates its input) so the /api/reasoning hot path keeps its cheap no-copy fast path; reload_config uses the safe default copy. --- api/config.py | 18 ++++++++++++++---- api/routes.py | 3 ++- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/api/config.py b/api/config.py index 32ebc5ce..e99f69bb 100644 --- a/api/config.py +++ b/api/config.py @@ -559,7 +559,7 @@ _yaml_file_cache: dict[str, tuple] = {} _yaml_file_cache_lock = threading.Lock() -def _load_yaml_config_file_raw(config_path: Path) -> dict: +def _load_yaml_config_file_raw(config_path: Path, *, _copy: bool = True) -> dict: """Return the RAW (un-env-expanded) parsed config dict, memoized on (resolved path, st_mtime_ns, st_size). Shared parse core for _load_yaml_config_file() and reload_config(): the former runs the helper's @@ -569,6 +569,11 @@ def _load_yaml_config_file_raw(config_path: Path) -> dict: (mtime, size) — a UI sync storm can't turn into a YAML-reparse storm (#4650), and an unchanged config.yaml isn't reparsed on the profile-switch hot path (#4662 Phase 2). + + By default returns a deep copy so a caller can never mutate the shared cache + entry (greptile #4741). Internal callers that immediately pass the result + through _expand_env_vars() (which itself returns a fresh structure and never + mutates its input) pass _copy=False to skip the redundant copy on the hot path. """ try: import yaml as _yaml @@ -587,7 +592,9 @@ def _load_yaml_config_file_raw(config_path: Path) -> dict: cached = _yaml_file_cache.get(cache_key) if cached is not None and cached[0] == stat_key: raw = cached[1] - return raw if isinstance(raw, dict) else {} + if not isinstance(raw, dict): + return {} + return copy.deepcopy(raw) if _copy else raw # Cache miss / stale: parse off disk. Done outside the lock so a slow parse # doesn't serialize unrelated paths; a concurrent duplicate parse is harmless. @@ -600,11 +607,14 @@ def _load_yaml_config_file_raw(config_path: Path) -> dict: raw = loaded if isinstance(loaded, dict) else {} with _yaml_file_cache_lock: _yaml_file_cache[cache_key] = (stat_key, raw) - return raw + return copy.deepcopy(raw) if _copy else raw def _load_yaml_config_file(config_path: Path) -> dict: - raw = _load_yaml_config_file_raw(config_path) + # _copy=False: _expand_env_vars returns a fresh structure and never mutates + # its input, so the env-expanded result is already cache-safe — no need to + # deep-copy the raw dict first (keeps the /api/reasoning hot path cheap). + raw = _load_yaml_config_file_raw(config_path, _copy=False) if not raw: return {} expanded = _expand_env_vars(raw) diff --git a/api/routes.py b/api/routes.py index 165ac167..a4bdd1cc 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2131,8 +2131,9 @@ def _session_list_payload_to_response(payload: dict) -> dict: # plumbing already exists; this wires the caller so the sidebar list path gets the # same read-once optimization redact_session_data() already uses. On a large list # this was the multi-second response_write stage in /api/sessions diagnostics. (#4662 Phase 3) + # load_settings is imported at module scope (below); this function only runs at + # request time, well after module load, so no lazy import is needed. try: - from api.config import load_settings _redact_enabled = bool(load_settings().get("api_redact_enabled", True)) except Exception: _redact_enabled = True # fail safe: redact when settings are unreadable From 028fb61f3a85278a7072c51a8b56cd0ff3f572e7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 00:50:12 -0400 Subject: [PATCH 07/45] fix(#4346): suppress footer jitter during virtual scroll measurement re-renders --- static/style.css | 2 + static/ui.js | 3 +- tests/test_issue4346_vscroll_footer_jitter.py | 50 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue4346_vscroll_footer_jitter.py diff --git a/static/style.css b/static/style.css index dd9c8cbb..4e85c8e7 100644 --- a/static/style.css +++ b/static/style.css @@ -2728,6 +2728,8 @@ /* ── Message action buttons (copy, edit, retry) ── */ .msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;} .msg-row:hover .msg-actions{opacity:1;} +.vscroll-measuring .msg-foot, +.vscroll-measuring .msg-actions{transition:none !important;} .msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;} .msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);} .selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;user-select:none;} diff --git a/static/ui.js b/static/ui.js index 938b9857..1c9f759c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -871,7 +871,8 @@ function _compensateScrollForMeasurementDelta(renderFn){ if(!container) return renderFn(); const anchorBefore=_captureMessageViewportAnchor(); const scrollTopBefore=container.scrollTop; - renderFn(); + container.classList.add('vscroll-measuring'); + try{ renderFn(); }finally{ container.classList.remove('vscroll-measuring'); } if(!anchorBefore) return; if(scrollTopBefore<1){ const spacer=container.querySelector('[data-virtual-spacer="before"]'); diff --git a/tests/test_issue4346_vscroll_footer_jitter.py b/tests/test_issue4346_vscroll_footer_jitter.py new file mode 100644 index 00000000..5df5de07 --- /dev/null +++ b/tests/test_issue4346_vscroll_footer_jitter.py @@ -0,0 +1,50 @@ +"""Static source assertions for issue #4346 virtual-scroll footer jitter fix.""" +import pathlib +import re + +ROOT = pathlib.Path(__file__).parent.parent + +CSS = (ROOT / 'static' / 'style.css').read_text(encoding='utf-8') +JS = (ROOT / 'static' / 'ui.js').read_text(encoding='utf-8') + + +def test_css_vscroll_measuring_guard(): + """style.css suppresses opacity transitions on .msg-foot and .msg-actions + while .vscroll-measuring is present on the scroll container.""" + assert 'vscroll-measuring' in CSS + assert re.search( + r'\.vscroll-measuring\s+\.msg-foot.*transition\s*:\s*none\s*!important', + CSS, re.DOTALL + ), "missing transition:none !important for .vscroll-measuring .msg-foot" + assert re.search( + r'\.vscroll-measuring\s+\.msg-actions.*transition\s*:\s*none\s*!important', + CSS, re.DOTALL + ), "missing transition:none !important for .vscroll-measuring .msg-actions" + + +def test_js_compensate_adds_vscroll_measuring(): + """_compensateScrollForMeasurementDelta adds and removes the vscroll-measuring + class around the render callback.""" + fn_match = re.search( + r'function _compensateScrollForMeasurementDelta\(renderFn\)\{(.+?)^(?=function )', + JS, re.DOTALL | re.MULTILINE + ) + assert fn_match, "_compensateScrollForMeasurementDelta not found" + body = fn_match.group(1) + assert "classList.add('vscroll-measuring')" in body + assert "classList.remove('vscroll-measuring')" in body + + +def test_js_try_finally_guards_class_removal(): + """The classList.remove is in a finally block so the class is always cleared.""" + fn_match = re.search( + r'function _compensateScrollForMeasurementDelta\(renderFn\)\{(.+?)^(?=function )', + JS, re.DOTALL | re.MULTILINE + ) + assert fn_match + body = fn_match.group(1) + add_pos = body.find("classList.add('vscroll-measuring')") + try_pos = body.find('try{', add_pos) if add_pos != -1 else -1 + finally_pos = body.find('finally{', try_pos) if try_pos != -1 else -1 + remove_pos = body.find("classList.remove('vscroll-measuring')", finally_pos) if finally_pos != -1 else -1 + assert remove_pos != -1, "classList.remove must be inside a finally block after classList.add" From 8b74045bf849134cb6cf056f8c134cc0d274af67 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 00:58:32 -0400 Subject: [PATCH 08/45] =?UTF-8?q?fix(#4346):=20address=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20classList=20fakes,=20.msg-time=20guard,=20test?= =?UTF-8?q?=20precision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/style.css | 3 ++- tests/test_issue4346_vscroll_footer_jitter.py | 19 +++++++++++++------ ...st_issue500_message_list_virtualization.py | 4 ++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/static/style.css b/static/style.css index 4e85c8e7..3577982b 100644 --- a/static/style.css +++ b/static/style.css @@ -2729,7 +2729,8 @@ .msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;} .msg-row:hover .msg-actions{opacity:1;} .vscroll-measuring .msg-foot, -.vscroll-measuring .msg-actions{transition:none !important;} +.vscroll-measuring .msg-actions, +.vscroll-measuring .msg-time{transition:none !important;} .msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;} .msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);} .selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;user-select:none;} diff --git a/tests/test_issue4346_vscroll_footer_jitter.py b/tests/test_issue4346_vscroll_footer_jitter.py index 5df5de07..6b6353b0 100644 --- a/tests/test_issue4346_vscroll_footer_jitter.py +++ b/tests/test_issue4346_vscroll_footer_jitter.py @@ -20,6 +20,10 @@ def test_css_vscroll_measuring_guard(): r'\.vscroll-measuring\s+\.msg-actions.*transition\s*:\s*none\s*!important', CSS, re.DOTALL ), "missing transition:none !important for .vscroll-measuring .msg-actions" + assert re.search( + r'\.vscroll-measuring\s+\.msg-time.*transition\s*:\s*none\s*!important', + CSS, re.DOTALL + ), "missing transition:none !important for .vscroll-measuring .msg-time" def test_js_compensate_adds_vscroll_measuring(): @@ -36,15 +40,18 @@ def test_js_compensate_adds_vscroll_measuring(): def test_js_try_finally_guards_class_removal(): - """The classList.remove is in a finally block so the class is always cleared.""" + """The classList.remove is inside the finally{} block, not after it.""" fn_match = re.search( r'function _compensateScrollForMeasurementDelta\(renderFn\)\{(.+?)^(?=function )', JS, re.DOTALL | re.MULTILINE ) assert fn_match body = fn_match.group(1) - add_pos = body.find("classList.add('vscroll-measuring')") - try_pos = body.find('try{', add_pos) if add_pos != -1 else -1 - finally_pos = body.find('finally{', try_pos) if try_pos != -1 else -1 - remove_pos = body.find("classList.remove('vscroll-measuring')", finally_pos) if finally_pos != -1 else -1 - assert remove_pos != -1, "classList.remove must be inside a finally block after classList.add" + finally_match = re.search( + r'finally\{([^}]*)\}', + body + ) + assert finally_match, "no finally block found in _compensateScrollForMeasurementDelta" + finally_body = finally_match.group(1) + assert "classList.remove('vscroll-measuring')" in finally_body, \ + "classList.remove must be inside the finally{} block, not after it" diff --git a/tests/test_issue500_message_list_virtualization.py b/tests/test_issue500_message_list_virtualization.py index 80aeabbb..601dc222 100644 --- a/tests/test_issue500_message_list_virtualization.py +++ b/tests/test_issue500_message_list_virtualization.py @@ -878,6 +878,7 @@ const container = { set scrollTop(v){ scrollTopWasMutated = true; scrollTopValue = v; }, getBoundingClientRect(){ return {top: 0}; }, querySelector(){ return null; }, + classList: { add(){}, remove(){} }, }; function $(id){ return id === 'messages' ? container : null; } function _captureMessageViewportAnchor(){ return null; } @@ -908,6 +909,7 @@ const container = { get scrollTop(){ return scrollTopValue; }, set scrollTop(v){ scrollHistory.push(v); scrollTopValue = v; }, getBoundingClientRect(){ return {top: 50, bottom: 650}; }, + classList: { add(){}, remove(){} }, querySelector(selector){ if(selector === '[data-msg-idx="42"]'){ // After render, the row moved from relative offset 100 to 150 (50px shift) @@ -954,6 +956,7 @@ const container = { get scrollTop(){ return scrollTopValue; }, set scrollTop(v){ scrollHistory.push(v); scrollTopValue = v; }, getBoundingClientRect(){ return {top: 50, bottom: 650}; }, + classList: { add(){}, remove(){} }, querySelector(selector){ if(selector === '[data-msg-idx="42"]'){ // Row moved by only 1px, within tolerance @@ -1002,6 +1005,7 @@ const container = { scrollTopValue = v; }, getBoundingClientRect(){ return {top: 50, bottom: 650}; }, + classList: { add(){}, remove(){} }, querySelector(selector){ if(selector === '[data-msg-idx="42"]'){ return { From 2f7fe9da325a074d76ff12971a55f8fee0add5f4 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 01:08:37 -0400 Subject: [PATCH 09/45] fix(#4346): harden footer jitter cleanup test --- tests/test_issue4346_vscroll_footer_jitter.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_issue4346_vscroll_footer_jitter.py b/tests/test_issue4346_vscroll_footer_jitter.py index 6b6353b0..1ac34c8f 100644 --- a/tests/test_issue4346_vscroll_footer_jitter.py +++ b/tests/test_issue4346_vscroll_footer_jitter.py @@ -47,11 +47,11 @@ def test_js_try_finally_guards_class_removal(): ) assert fn_match body = fn_match.group(1) - finally_match = re.search( - r'finally\{([^}]*)\}', - body - ) - assert finally_match, "no finally block found in _compensateScrollForMeasurementDelta" - finally_body = finally_match.group(1) - assert "classList.remove('vscroll-measuring')" in finally_body, \ - "classList.remove must be inside the finally{} block, not after it" + try_idx = body.find('try{') + finally_idx = body.find('finally{') + remove_idx = body.find("classList.remove('vscroll-measuring')") + assert try_idx != -1, "no try block found in _compensateScrollForMeasurementDelta" + assert finally_idx != -1, "no finally block found in _compensateScrollForMeasurementDelta" + assert remove_idx != -1, "missing classList.remove('vscroll-measuring')" + assert try_idx < finally_idx < remove_idx, \ + "classList.remove must remain in the finally{} cleanup path" From c1355f9e6a0fb7c8e29134796de6723d1780602f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 14:47:46 -0400 Subject: [PATCH 10/45] Clarify the measurement transition guard and make its test precise --- static/style.css | 1 + tests/test_issue4346_vscroll_footer_jitter.py | 20 ++++++++----------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/static/style.css b/static/style.css index 3577982b..81b10888 100644 --- a/static/style.css +++ b/static/style.css @@ -2728,6 +2728,7 @@ /* ── Message action buttons (copy, edit, retry) ── */ .msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;} .msg-row:hover .msg-actions{opacity:1;} +/* Keep !important so this beats equal-specificity .msg-foot-with-usage fades during measurement rerenders. */ .vscroll-measuring .msg-foot, .vscroll-measuring .msg-actions, .vscroll-measuring .msg-time{transition:none !important;} diff --git a/tests/test_issue4346_vscroll_footer_jitter.py b/tests/test_issue4346_vscroll_footer_jitter.py index 1ac34c8f..d7afca96 100644 --- a/tests/test_issue4346_vscroll_footer_jitter.py +++ b/tests/test_issue4346_vscroll_footer_jitter.py @@ -12,18 +12,14 @@ def test_css_vscroll_measuring_guard(): """style.css suppresses opacity transitions on .msg-foot and .msg-actions while .vscroll-measuring is present on the scroll container.""" assert 'vscroll-measuring' in CSS - assert re.search( - r'\.vscroll-measuring\s+\.msg-foot.*transition\s*:\s*none\s*!important', - CSS, re.DOTALL - ), "missing transition:none !important for .vscroll-measuring .msg-foot" - assert re.search( - r'\.vscroll-measuring\s+\.msg-actions.*transition\s*:\s*none\s*!important', - CSS, re.DOTALL - ), "missing transition:none !important for .vscroll-measuring .msg-actions" - assert re.search( - r'\.vscroll-measuring\s+\.msg-time.*transition\s*:\s*none\s*!important', - CSS, re.DOTALL - ), "missing transition:none !important for .vscroll-measuring .msg-time" + guard_match = re.search( + r'(?m)^\.vscroll-measuring\s+\.msg-foot,\n' + r'^\.vscroll-measuring\s+\.msg-actions,\n' + r'^\.vscroll-measuring\s+\.msg-time\{transition:none !important;\}$', + CSS, + ) + assert guard_match, \ + "missing contiguous .vscroll-measuring transition:none !important guard block" def test_js_compensate_adds_vscroll_measuring(): From f748854a69cdab68f0e8834538cde46322a611b7 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 05:49:23 +0000 Subject: [PATCH 11/45] =?UTF-8?q?Release=20v0.51.600=20=E2=80=94=20Release?= =?UTF-8?q?=20VG=20(suppress=20footer=20jitter=20during=20virtual-scroll?= =?UTF-8?q?=20measurement,=20#4474)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 765f626f..230f4ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.600] — 2026-06-23 — Release VG (suppress footer jitter during virtual-scroll measurement) + +### Fixed + +- **The message footer (timestamp + usage stats + actions) no longer flickers/jitters during virtualized-list measurement re-renders.** While the virtual transcript re-measured row heights, the footer's fade transitions fired on each measurement pass, producing a visible jitter. Footer transitions are now suppressed for the duration of the measurement re-render only (a scoped `vscroll-measuring` class, removed in a `finally` so it can never leak), so the footer stays stable while scrolling a long conversation. Thanks @rodboev. (#4474) + ## [v0.51.599] — 2026-06-23 — Release VF (dedupe live progress reasoning echoes) ### Fixed From c7034655b67f4050726a473af47c8addbc95a118 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 06:17:44 +0000 Subject: [PATCH 12/45] =?UTF-8?q?Release=20v0.51.601=20=E2=80=94=20Release?= =?UTF-8?q?=20VH=20(sidebar=20lineage=20running/unread=20state=20when=20co?= =?UTF-8?q?ntinuation=20hidden,=20#4451)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 871f6464..2652ead6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ ## [Unreleased] +## [v0.51.601] — 2026-06-23 — Release VH (sidebar lineage rows keep running/unread state when continuation is hidden) + ### Fixed -- Sidebar lineage rows now keep their running spinner, latest activity ordering, and unread/attention state when the active continuation is hidden as an archived lineage child. Pin/unpin actions also update the sidebar ordering immediately before the next server refresh. +- Sidebar lineage rows now keep their running spinner, latest activity ordering, and unread/attention state when the active continuation is hidden as an archived lineage child. Pin/unpin actions also update the sidebar ordering immediately before the next server refresh. Thanks @santastabber. (#4451) ## [v0.51.600] — 2026-06-23 — Release VG (suppress footer jitter during virtual-scroll measurement) From 9a5a3eb6d50ec01a9bb1ea073c4a0e6dc44e1b0d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 05:56:00 -0400 Subject: [PATCH 13/45] fix(#4766): filter /api/sessions by active source bucket --- api/routes.py | 24 ++ static/sessions.js | 28 +- .../test_issue4766_sidebar_source_pushdown.py | 240 ++++++++++++++++++ 3 files changed, 285 insertions(+), 7 deletions(-) create mode 100644 tests/test_issue4766_sidebar_source_pushdown.py diff --git a/api/routes.py b/api/routes.py index 25796f0b..c6c4e404 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1598,6 +1598,7 @@ def _session_list_cache_key( show_cron_sessions: bool, include_archived: bool = False, source_filter: str | None = None, + sidebar_source: str | None = None, ) -> tuple: return ( _session_list_cache_profile_scope(active_profile), @@ -1607,6 +1608,7 @@ def _session_list_cache_key( bool(show_cron_sessions), bool(include_archived), source_filter, + sidebar_source, ) @@ -1925,6 +1927,7 @@ def _build_session_list_cache_payload( show_cron_sessions: bool, include_archived: bool = False, source_filter: str | None = None, + sidebar_source: str | None = None, diag=None, ) -> dict: diag_stage = diag.stage if diag is not None else lambda *_a, **_k: None @@ -2097,6 +2100,18 @@ def _build_session_list_cache_payload( ) archived_count = archived_webui_count + archived_cli_count scoped = archived_scoped if include_archived else visible_scoped + webui_session_count = sum( + 1 for s in scoped + if not _is_cli_session_for_settings(s) + ) + cli_session_count = sum( + 1 for s in scoped + if _is_cli_session_for_settings(s) + ) + if sidebar_source == "webui": + scoped = [s for s in scoped if not _is_cli_session_for_settings(s)] + elif sidebar_source == "cli": + scoped = [s for s in scoped if _is_cli_session_for_settings(s)] if not include_archived: diag_stage("filter_archived_sessions") diag_stage("visible_lineage_metadata") @@ -2110,6 +2125,8 @@ def _build_session_list_cache_payload( "archived_count": archived_count, "archived_webui_count": archived_webui_count, "archived_cli_count": archived_cli_count, + "webui_session_count": webui_session_count, + "cli_session_count": cli_session_count, "include_archived": include_archived, "all_profiles": all_profiles, "active_profile": active_profile, @@ -2134,6 +2151,8 @@ def _session_list_payload_to_response(payload: dict) -> dict: "archived_count": int(payload.get("archived_count", 0)), "archived_webui_count": int(payload.get("archived_webui_count", 0)), "archived_cli_count": int(payload.get("archived_cli_count", 0)), + "webui_session_count": int(payload.get("webui_session_count", 0)), + "cli_session_count": int(payload.get("cli_session_count", 0)), "include_archived": bool(payload.get("include_archived", False)), "all_profiles": bool(payload.get("all_profiles", False)), "active_profile": payload.get("active_profile"), @@ -9733,6 +9752,9 @@ def handle_get(handler, parsed) -> bool: active_profile = get_active_profile_name() all_profiles = _all_profiles_enabled(parsed) include_archived = _query_flag(parsed, "include_archived") + sidebar_source = parse_qs(parsed.query).get("sidebar_source", [""])[0].strip().lower() or None + if sidebar_source not in ("webui", "cli"): + sidebar_source = None key = _session_list_cache_key( active_profile=active_profile, all_profiles=all_profiles, @@ -9741,6 +9763,7 @@ def handle_get(handler, parsed) -> bool: show_cron_sessions=show_cron_sessions, include_archived=include_archived, source_filter=agent_session_source_filter, + sidebar_source=sidebar_source, ) # Keep the visible /api/sessions contract unchanged even though the # heavy lifting now lives in the cache builder: profile scoping via @@ -9756,6 +9779,7 @@ def handle_get(handler, parsed) -> bool: show_cron_sessions=show_cron_sessions, include_archived=include_archived, source_filter=agent_session_source_filter, + sidebar_source=sidebar_source, diag=diag, ), diag=diag, diff --git a/static/sessions.js b/static/sessions.js index c5606e3a..5863de55 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2673,6 +2673,8 @@ let _showAllProfiles = false; // false = filter to active profile only let _otherProfileCount = 0; // count of sessions from other profiles (server-reported) let _archivedWebuiCount = 0; // archived WebUI sessions not fetched until requested let _archivedCliCount = 0; // archived non-WebUI sessions not fetched until requested +let _serverWebuiSessionCount = null; // explicit server count for WebUI sessions +let _serverCliSessionCount = null; // explicit server count for CLI sessions let _sessionSourceFilter = 'webui'; // 'webui' keeps WebUI chats separate from read-only CLI sessions _restoreSessionSourceFilter(); let _sessionActionMenu = null; @@ -3746,6 +3748,14 @@ function _applySessionListPayload(sessData, projData){ _otherProfileCount = sessData.other_profile_count || 0; _archivedWebuiCount = Number(sessData.archived_webui_count ?? sessData.archived_count ?? 0); _archivedCliCount = Number(sessData.archived_cli_count ?? 0); + _serverWebuiSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'webui_session_count') + ? Number(sessData.webui_session_count) + : null; + _serverCliSessionCount = Object.prototype.hasOwnProperty.call(sessData, 'cli_session_count') + ? Number(sessData.cli_session_count) + : null; + if (!Number.isFinite(_serverWebuiSessionCount)) _serverWebuiSessionCount = null; + if (!Number.isFinite(_serverCliSessionCount)) _serverCliSessionCount = null; // Capture server clock for clock-skew compensation (issue #1144). // server_time is epoch seconds from the server's time.time(). // _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta) @@ -3827,6 +3837,8 @@ async function _runRenderSessionListRefresh(opts, _gen){ try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; const qs = new URLSearchParams(); + const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; + qs.set('sidebar_source', requestSidebarSource); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); const sessionListQS = qs.toString() ? `?${qs.toString()}` : ''; @@ -5550,12 +5562,14 @@ function renderSessionListFromCache(){ }=_partitionSidebarSessionRows(allMatched, activeSidForSidebar); const referenceRaw=_sessionSourceFilter==='cli'?cliReferenceRaw:webuiReferenceRaw; const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw); - const renderedWebuiSessionCount=_sessionSourceFilter==='webui' - ? sessions.length - : _renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length; - const renderedCliSessionCount=_sessionSourceFilter==='cli' - ? sessions.length - : _renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length; + const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length; + const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length; + const webuiSessionTabCount=Number.isFinite(_serverWebuiSessionCount) + ? _serverWebuiSessionCount + : renderedWebuiSessionCount; + const cliSessionTabCount=Number.isFinite(_serverCliSessionCount) + ? _serverCliSessionCount + : renderedCliSessionCount; _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); const list=$('sessionList'); const animateRefresh=_sessionListRefreshAnimationPending; @@ -5616,7 +5630,7 @@ function renderSessionListFromCache(){ const sourceTabs=document.createElement('div'); sourceTabs.className='session-source-tabs'; for(const filter of ['webui','cli']){ - const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount; + const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount; const btn=document.createElement('button'); btn.type='button'; btn.className='session-source-tab'+(_sessionSourceFilter===filter?' active':''); diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py new file mode 100644 index 00000000..3331baaf --- /dev/null +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -0,0 +1,240 @@ +"""Regression coverage for issue #4766: `/api/sessions` filters by active sidebar source.""" + +import io +import json +from pathlib import Path +from urllib.parse import urlparse + +import api.profiles as profiles +import api.routes as routes +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = ROOT / "static" / "sessions.js" + + +class _FakeHandler: + def __init__(self): + self.status = None + self.headers = {} + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.headers[key] = value + + def end_headers(self): + pass + + def json_body(self): + return json.loads(self.wfile.getvalue().decode("utf-8")) + + +def _session_rows( + webui_count, + cli_count, + archived_webui_count=0, + archived_cli_count=0, + start=0, +): + rows = [] + for index in range(webui_count): + rows.append( + { + "session_id": f"webui-{start + index}", + "title": "WebUI Session", + "profile": "default", + "archived": index < archived_webui_count, + "message_count": 1, + "updated_at": 1000 + index, + "last_message_at": 1000 + index, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + } + ) + for index in range(cli_count): + rows.append( + { + "session_id": f"cli-{start + index + 10000}", + "title": "Imported CLI session", + "profile": "default", + "archived": index < archived_cli_count, + "message_count": 1, + "updated_at": 2000 + index, + "last_message_at": 2000 + index, + "source": "cli", + "raw_source": "cli", + "session_source": "cli", + "source_tag": "cli", + } + ) + return rows + + +def _handle_sessions(url): + handler = _FakeHandler() + routes.handle_get(handler, urlparse(url)) + return handler + + +@pytest.fixture(autouse=True) +def _clear_cache(): + routes._session_list_cache_clear() + yield + routes._session_list_cache_clear() + + +def _install_common_monkeypatches(monkeypatch, rows): + enriched = [] + row_ids = {str(row["session_id"]) for row in rows if row.get("session_id")} + monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows)) + monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _rows: False) + monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", lambda rows: enriched.append([r["session_id"] for r in rows])) + monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: []) + monkeypatch.setattr(routes, "agent_session_rows_existing", lambda ids, profile=None: set(row_ids & {str(sid) for sid in ids})) + monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": True}) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") + return enriched + + +def test_sidebar_source_webui_excludes_cli_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + enriched = _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 30 + assert all(r["session_id"].startswith("webui-") for r in body["sessions"]) + assert body["webui_session_count"] == 30 + assert body["cli_session_count"] == 20 + assert body["archived_count"] == 0 + expected = { + row["session_id"] for row in rows + if not row["archived"] and row["session_id"].startswith("webui-") + } + assert set(enriched[0]) == expected + + +def test_sidebar_source_cli_excludes_webui_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=cli") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 20 + assert all(r["session_id"].startswith("cli-") for r in body["sessions"]) + assert body["webui_session_count"] == 30 + assert body["cli_session_count"] == 20 + + +def test_sidebar_source_omitted_returns_all_rows(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions") + + body = handler.json_body() + assert handler.status == 200 + assert len(body["sessions"]) == 50 + assert len([r for r in body["sessions"] if r["session_id"].startswith("webui-")]) == 30 + assert len([r for r in body["sessions"] if r["session_id"].startswith("cli-")]) == 20 + + +def test_sidebar_source_returns_cross_bucket_counts(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20, archived_webui_count=2, archived_cli_count=3) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&include_archived=1") + webui_rows = [r for r in rows if r["session_id"].startswith("webui-")] + cli_rows = [r for r in rows if r["session_id"].startswith("cli-")] + + body = handler.json_body() + assert handler.status == 200 + assert body["webui_session_count"] == len(webui_rows) + assert body["cli_session_count"] == len(cli_rows) + + +def test_sidebar_source_preserves_archived_counts(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20, archived_webui_count=2, archived_cli_count=3) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&include_archived=1") + body = handler.json_body() + + assert handler.status == 200 + assert body["archived_webui_count"] == 2 + assert body["archived_cli_count"] == 3 + assert body["archived_count"] == 5 + assert len([r for r in body["sessions"] if r["archived"]]) == 2 + + +def test_sidebar_source_varies_cache_key(): + key_webui = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source="webui", + ) + key_cli = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source="cli", + ) + key_omitted = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source=None, + ) + + assert key_webui != key_cli + assert key_webui != key_omitted + assert key_cli != key_omitted + + +def test_frontend_sends_sidebar_source_param(): + src = SESSIONS_JS.read_text(encoding="utf-8") + + assert "const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui';" in src + assert "qs.set('sidebar_source', requestSidebarSource);" in src + assert "_serverWebuiSessionCount" in src + assert "_serverCliSessionCount" in src + assert "Number.isFinite(_serverWebuiSessionCount)" in src + assert "Number.isFinite(_serverCliSessionCount)" in src + assert "_sessionSourceLabel(filter,count)" in src + + +def test_frontend_avoids_cli_bucket_request_when_cli_hidden(): + src = SESSIONS_JS.read_text(encoding="utf-8") + + assert "window._showCliSessions ? _sessionSourceFilter : 'webui'" in src + + +def test_payload_row_count_regression(monkeypatch): + rows = _session_rows(webui_count=30, cli_count=20) + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui") + body = handler.json_body() + + assert handler.status == 200 + assert len(body["sessions"]) == 30 From 73a209aca7d1c34571bf8a2fec1c236e2edf992f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:10:16 -0400 Subject: [PATCH 14/45] fix(#4766): close review gaps in sidebar source filtering --- api/routes.py | 9 +- static/sessions.js | 2 +- .../test_issue4766_sidebar_source_pushdown.py | 84 +++++++++++++++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/api/routes.py b/api/routes.py index c6c4e404..019fbf1c 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2145,14 +2145,12 @@ def _session_list_payload_to_response(payload: dict) -> dict: for s in runtime_rows: item = _sidebar_session_response_item(s) if isinstance(s, dict) else {} safe_merged.append(item) - return { + response = { "sessions": safe_merged, "cli_count": int(payload.get("cli_count", 0)), "archived_count": int(payload.get("archived_count", 0)), "archived_webui_count": int(payload.get("archived_webui_count", 0)), "archived_cli_count": int(payload.get("archived_cli_count", 0)), - "webui_session_count": int(payload.get("webui_session_count", 0)), - "cli_session_count": int(payload.get("cli_session_count", 0)), "include_archived": bool(payload.get("include_archived", False)), "all_profiles": bool(payload.get("all_profiles", False)), "active_profile": payload.get("active_profile"), @@ -2160,6 +2158,11 @@ def _session_list_payload_to_response(payload: dict) -> dict: "server_time": time.time(), "server_tz": time.strftime("%z"), } + if "webui_session_count" in payload: + response["webui_session_count"] = int(payload.get("webui_session_count", 0)) + if "cli_session_count" in payload: + response["cli_session_count"] = int(payload.get("cli_session_count", 0)) + return response def _get_cached_session_list_payload( diff --git a/static/sessions.js b/static/sessions.js index 5863de55..30ed46f2 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1569,7 +1569,7 @@ function _setSessionSourceFilter(filter) { _selectedSessions.clear(); _sessionSelectMode = false; try { localStorage.setItem('hermes-session-source-filter', next); } catch (_e) {} - renderSessionListFromCache(); + void renderSessionList({deferWhileInteracting:false}); } function _restoreSessionSourceFilter() { diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 3331baaf..e81fb45a 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -3,6 +3,8 @@ import io import json from pathlib import Path +import shutil +import subprocess from urllib.parse import urlparse import api.profiles as profiles @@ -12,6 +14,7 @@ import pytest ROOT = Path(__file__).resolve().parents[1] SESSIONS_JS = ROOT / "static" / "sessions.js" +NODE = shutil.which("node") class _FakeHandler: @@ -82,6 +85,22 @@ def _handle_sessions(url): return handler +def _extract_function(source_text, function_name): + marker = f"function {function_name}(" + start = source_text.index(marker) + brace_start = source_text.index("{", start) + depth = 0 + for index in range(brace_start, len(source_text)): + char = source_text[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source_text[start : index + 1] + raise AssertionError(f"Could not extract {function_name}") + + @pytest.fixture(autouse=True) def _clear_cache(): routes._session_list_cache_clear() @@ -229,6 +248,71 @@ def test_frontend_avoids_cli_bucket_request_when_cli_hidden(): assert "window._showCliSessions ? _sessionSourceFilter : 'webui'" in src +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_session_source_switch_fetches_selected_bucket(): + src = SESSIONS_JS.read_text(encoding="utf-8") + fn_source = _extract_function(src, "_setSessionSourceFilter") + script = f""" +const renderCalls = []; +global._sessionSourceFilter = 'webui'; +global._activeProject = 'demo-project'; +global._selectedSessions = new Set(['first', 'second']); +global._sessionSelectMode = true; +global.localStorage = {{ + writes: [], + setItem(key, value) {{ + this.writes.push([key, value]); + }}, +}}; +global.renderSessionList = (opts) => {{ + renderCalls.push(opts); + return Promise.resolve(); +}}; +{fn_source} +_setSessionSourceFilter('cli'); +console.log(JSON.stringify({{ + sourceFilter: global._sessionSourceFilter, + activeProject: global._activeProject, + selectedSize: global._selectedSessions.size, + sessionSelectMode: global._sessionSelectMode, + storageWrites: global.localStorage.writes, + renderCalls, +}})); +""" + proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True) + body = json.loads(proc.stdout) + + assert body["sourceFilter"] == "cli" + assert body["activeProject"] is None + assert body["selectedSize"] == 0 + assert body["sessionSelectMode"] is False + assert body["storageWrites"] == [["hermes-session-source-filter", "cli"]] + assert body["renderCalls"] == [{"deferWhileInteracting": False}] + + +def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): + monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) + monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) + + body = routes._session_list_payload_to_response( + { + "sessions": [{"session_id": "webui-1", "title": "WebUI Session"}], + "cli_count": 0, + "archived_count": 0, + "archived_webui_count": 0, + "archived_cli_count": 0, + "include_archived": False, + "all_profiles": False, + "active_profile": "default", + "other_profile_count": 0, + } + ) + + assert "webui_session_count" not in body + assert "cli_session_count" not in body + assert body["sessions"][0]["session_id"] == "webui-1" + + def test_payload_row_count_regression(monkeypatch): rows = _session_rows(webui_count=30, cli_count=20) _install_common_monkeypatches(monkeypatch, rows) From 4ee5ef0d0196cc4546722686a79b8216316d23d6 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:21:15 -0400 Subject: [PATCH 15/45] fix(#4766): harden source-tab refresh state --- static/sessions.js | 36 ++++-- .../test_issue4766_sidebar_source_pushdown.py | 107 ++++++++++++++++-- 2 files changed, 123 insertions(+), 20 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 30ed46f2..604e28a9 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1561,6 +1561,26 @@ function _sessionSourceLabel(filter, count) { return filter === 'cli' ? `CLI sessions (${n})` : `WebUI sessions (${n})`; } +function _clearSessionSourceTabCounts() { + _serverWebuiSessionCount = null; + _serverCliSessionCount = null; +} + +function _sessionListQueryString() { + const qs = new URLSearchParams(); + const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; + qs.set('sidebar_source', requestSidebarSource); + if(_showAllProfiles) qs.set('all_profiles','1'); + if(_showArchived) qs.set('include_archived','1'); + return qs.toString() ? `?${qs.toString()}` : ''; +} + +function _sessionSourceTabCount(filter, renderedWebuiSessionCount, renderedCliSessionCount) { + const serverCount = filter === 'cli' ? _serverCliSessionCount : _serverWebuiSessionCount; + if (Number.isFinite(serverCount)) return serverCount; + return filter === 'cli' ? renderedCliSessionCount : renderedWebuiSessionCount; +} + function _setSessionSourceFilter(filter) { const next = filter === 'cli' ? 'cli' : 'webui'; if (_sessionSourceFilter === next) return; @@ -3836,12 +3856,7 @@ async function _runRenderSessionListRefresh(opts, _gen){ if(!deferWhileInteracting) _pendingSessionListPayload=null; try{ if(!($('sessionSearch').value||'').trim()) _contentSearchResults = []; - const qs = new URLSearchParams(); - const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; - qs.set('sidebar_source', requestSidebarSource); - if(_showAllProfiles) qs.set('all_profiles','1'); - if(_showArchived) qs.set('include_archived','1'); - const sessionListQS = qs.toString() ? `?${qs.toString()}` : ''; + const sessionListQS = _sessionListQueryString(); const sessionRequestOpts={ timeoutToast:false, timeoutMs:_sessionListHasLoadedOnce?30000:_SESSION_LIST_BOOT_TIMEOUT_MS, @@ -3902,6 +3917,7 @@ async function _runRenderSessionListRefresh(opts, _gen){ } else { _allSessions = []; _allSessionsScope = null; + _clearSessionSourceTabCounts(); renderSessionListFromCache(); } } @@ -5564,12 +5580,8 @@ function renderSessionListFromCache(){ const sessions=_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw); const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length; const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length; - const webuiSessionTabCount=Number.isFinite(_serverWebuiSessionCount) - ? _serverWebuiSessionCount - : renderedWebuiSessionCount; - const cliSessionTabCount=Number.isFinite(_serverCliSessionCount) - ? _serverCliSessionCount - : renderedCliSessionCount; + const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount); + const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount); _syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar); const list=$('sessionList'); const animateRefresh=_sessionListRefreshAnimationPending; diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index e81fb45a..3c0c922b 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -101,6 +101,11 @@ def _extract_function(source_text, function_name): raise AssertionError(f"Could not extract {function_name}") +def _run_node(script): + proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True) + return json.loads(proc.stdout) + + @pytest.fixture(autouse=True) def _clear_cache(): routes._session_list_cache_clear() @@ -233,19 +238,33 @@ def test_sidebar_source_varies_cache_key(): def test_frontend_sends_sidebar_source_param(): src = SESSIONS_JS.read_text(encoding="utf-8") - assert "const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui';" in src + assert "function _sessionListQueryString()" in src assert "qs.set('sidebar_source', requestSidebarSource);" in src assert "_serverWebuiSessionCount" in src assert "_serverCliSessionCount" in src - assert "Number.isFinite(_serverWebuiSessionCount)" in src - assert "Number.isFinite(_serverCliSessionCount)" in src - assert "_sessionSourceLabel(filter,count)" in src + assert "function _sessionSourceTabCount(" in src -def test_frontend_avoids_cli_bucket_request_when_cli_hidden(): +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_session_list_query_string_respects_sidebar_source_and_flags(): src = SESSIONS_JS.read_text(encoding="utf-8") + query_fn = _extract_function(src, "_sessionListQueryString") + script = f""" +global.window = {{ _showCliSessions: true }}; +global._sessionSourceFilter = 'cli'; +global._showAllProfiles = true; +global._showArchived = false; +{query_fn} +const first = _sessionListQueryString(); +window._showCliSessions = false; +global._showArchived = true; +const second = _sessionListQueryString(); +console.log(JSON.stringify({{ first, second }})); +""" + body = _run_node(script) - assert "window._showCliSessions ? _sessionSourceFilter : 'webui'" in src + assert body["first"] == "?sidebar_source=cli&all_profiles=1" + assert body["second"] == "?sidebar_source=webui&all_profiles=1&include_archived=1" @pytest.mark.skipif(NODE is None, reason="node not on PATH") @@ -279,8 +298,7 @@ console.log(JSON.stringify({{ renderCalls, }})); """ - proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True) - body = json.loads(proc.stdout) + body = _run_node(script) assert body["sourceFilter"] == "cli" assert body["activeProject"] is None @@ -290,6 +308,73 @@ console.log(JSON.stringify({{ assert body["renderCalls"] == [{"deferWhileInteracting": False}] +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_apply_payload_and_tab_count_helpers_cover_old_and_new_payloads(): + src = SESSIONS_JS.read_text(encoding="utf-8") + apply_fn = _extract_function(src, "_applySessionListPayload") + count_fn = _extract_function(src, "_sessionSourceTabCount") + clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") + script = f""" +global._otherProfileCount = 0; +global._archivedWebuiCount = 0; +global._archivedCliCount = 0; +global._serverWebuiSessionCount = null; +global._serverCliSessionCount = null; +global._serverTimeDelta = 0; +global._serverTz = null; +global._optimisticallyRemovedSessionIds = new Set(); +global._allSessions = []; +global._allSessionsScope = null; +global._allProjects = []; +global._sessionListLoadError = null; +global._sessionListHasLoadedOnce = false; +global._sessionListFirstRenderAnimated = true; +global._sessionListSkeletonActive = true; +global._showAllProfiles = false; +global.S = {{ activeProfile: 'default' }}; +global._reconcileActiveSessionIdleStateFromList = rows => rows; +global._mergeOptimisticFirstTurnSessions = rows => rows; +global._syncSessionAttentionSoundState = () => {{}}; +global._pruneLineageReportCacheToVisibleSessions = () => {{}}; +global._markPollingCompletionUnreadTransitions = () => {{}}; +global._isSessionEffectivelyStreaming = () => false; +global.startStreamingPoll = () => {{}}; +global.stopStreamingPoll = () => {{}}; +global.ensureSessionTimeRefreshPoll = () => {{}}; +global.ensureActiveSessionExternalRefreshPoll = () => {{}}; +global.ensureSessionEventsSSE = () => {{}}; +global.animateNextSessionListRefresh = () => {{}}; +global.renderSessionListFromCache = () => {{}}; +{clear_fn} +{count_fn} +{apply_fn} +const sessions = [{{ session_id: 'webui-1' }}]; +_applySessionListPayload({{ sessions, other_profile_count: 0, archived_count: 0, active_profile: 'default' }}, {{ projects: [] }}); +const oldPayload = {{ + webui: _sessionSourceTabCount('webui', 7, 3), + cli: _sessionSourceTabCount('cli', 7, 3), +}}; +_clearSessionSourceTabCounts(); +_applySessionListPayload({{ + sessions, + other_profile_count: 0, + archived_count: 0, + active_profile: 'default', + webui_session_count: 11, + cli_session_count: 5, +}}, {{ projects: [] }}); +const newPayload = {{ + webui: _sessionSourceTabCount('webui', 7, 3), + cli: _sessionSourceTabCount('cli', 7, 3), +}}; +console.log(JSON.stringify({{ oldPayload, newPayload }})); +""" + body = _run_node(script) + + assert body["oldPayload"] == {"webui": 7, "cli": 3} + assert body["newPayload"] == {"webui": 11, "cli": 5} + + def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) @@ -313,6 +398,12 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): assert body["sessions"][0]["session_id"] == "webui-1" +def test_scope_mismatch_error_path_clears_server_tab_counts(): + src = SESSIONS_JS.read_text(encoding="utf-8") + + assert "_clearSessionSourceTabCounts();" in src + + def test_payload_row_count_regression(monkeypatch): rows = _session_rows(webui_count=30, cli_count=20) _install_common_monkeypatches(monkeypatch, rows) From 534008c3fce79e8c0350d4d2dd7d5dd551d8fdee Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:39:34 -0400 Subject: [PATCH 16/45] Preserve hidden source runtime state across tab refreshes --- static/sessions.js | 38 +++++++-- .../test_issue4766_sidebar_source_pushdown.py | 79 ++++++++++++++++++- 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 604e28a9..d43a9e63 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -175,6 +175,7 @@ let _sessionCompletionUnread = null; let _sessionObservedStreaming = null; const _sessionStreamingById = new Map(); const _sessionListSnapshotById = new Map(); +const _sessionListSourceById = new Map(); let _sessionListPointerActive = false; let _sessionListLastScrollAt = 0; let _pendingSessionListPayload = null; @@ -449,6 +450,9 @@ function _purgeStaleInflightEntries() { if (s && s.session_id) sessionsById.set(s.session_id, s); } } + const currentSidebarSource = _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string' + ? _allSessionsScope.sidebarSource + : null; for (const sid of Object.keys(INFLIGHT)) { // #4354: purge stale INFLIGHT even for a hung/idle session, BUT skip the one // session actively mid-send (#2689 start-race) — during /api/chat/start the @@ -458,6 +462,10 @@ function _purgeStaleInflightEntries() { continue; } if (!sessionsById.has(sid)) { + const knownSource = _sessionListSourceById.get(sid); + if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) { + continue; + } // Session is absent from _allSessions — it was deleted / archived / // filtered and can never stream again, so drop the entry. delete INFLIGHT[sid]; @@ -625,6 +633,7 @@ function _markSessionCompletedInList(session, previousSid = null) { _sessionStreamingById.delete(previousSid); _forgetObservedStreamingSession(previousSid); _sessionListSnapshotById.delete(previousSid); + _sessionListSourceById.delete(previousSid); } _sessionListSnapshotById.set(finalSid, { message_count: messageCount, @@ -636,10 +645,14 @@ function _markSessionCompletedInList(session, previousSid = null) { function _markPollingCompletionUnreadTransitions(sessions) { if (!Array.isArray(sessions)) return; const seen = new Set(); + const currentSidebarSource = _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string' + ? _allSessionsScope.sidebarSource + : null; for (const s of sessions) { if (!s || !s.session_id) continue; const sid = s.session_id; seen.add(sid); + _sessionListSourceById.set(sid, _isCliSession(s) ? 'cli' : 'webui'); const wasStreaming = _sessionStreamingById.get(sid); const isStreaming = _isSessionEffectivelyStreaming(s); const previousSnapshot = _sessionListSnapshotById.get(sid); @@ -677,11 +690,18 @@ function _markPollingCompletionUnreadTransitions(sessions) { last_message_at: lastMessageAt, }); } - for (const sid of Array.from(_sessionStreamingById.keys())) { - if (!seen.has(sid)) _sessionStreamingById.delete(sid); - } - for (const sid of Array.from(_sessionListSnapshotById.keys())) { - if (!seen.has(sid)) _sessionListSnapshotById.delete(sid); + const staleRuntimeStateSids = new Set([ + ...Array.from(_sessionStreamingById.keys()), + ...Array.from(_sessionListSnapshotById.keys()), + ...Array.from(_sessionListSourceById.keys()), + ]); + for (const sid of staleRuntimeStateSids) { + if (seen.has(sid)) continue; + const knownSource = _sessionListSourceById.get(sid); + if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) continue; + _sessionStreamingById.delete(sid); + _sessionListSnapshotById.delete(sid); + _sessionListSourceById.delete(sid); } } @@ -1566,10 +1586,13 @@ function _clearSessionSourceTabCounts() { _serverCliSessionCount = null; } +function _requestedSessionSidebarSource() { + return window._showCliSessions ? _sessionSourceFilter : 'webui'; +} + function _sessionListQueryString() { const qs = new URLSearchParams(); - const requestSidebarSource = window._showCliSessions ? _sessionSourceFilter : 'webui'; - qs.set('sidebar_source', requestSidebarSource); + qs.set('sidebar_source', _requestedSessionSidebarSource()); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); return qs.toString() ? `?${qs.toString()}` : ''; @@ -3800,6 +3823,7 @@ function _applySessionListPayload(sessData, projData){ ? sessData.active_profile : (S.activeProfile || 'default'), allProfiles: !!_showAllProfiles, + sidebarSource: _requestedSessionSidebarSource(), }; _syncSessionAttentionSoundState(_allSessions); _pruneLineageReportCacheToVisibleSessions(_allSessions); diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 3c0c922b..f900dbcf 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -238,8 +238,9 @@ def test_sidebar_source_varies_cache_key(): def test_frontend_sends_sidebar_source_param(): src = SESSIONS_JS.read_text(encoding="utf-8") + assert "function _requestedSessionSidebarSource()" in src assert "function _sessionListQueryString()" in src - assert "qs.set('sidebar_source', requestSidebarSource);" in src + assert "qs.set('sidebar_source', _requestedSessionSidebarSource());" in src assert "_serverWebuiSessionCount" in src assert "_serverCliSessionCount" in src assert "function _sessionSourceTabCount(" in src @@ -248,12 +249,14 @@ def test_frontend_sends_sidebar_source_param(): @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_session_list_query_string_respects_sidebar_source_and_flags(): src = SESSIONS_JS.read_text(encoding="utf-8") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") query_fn = _extract_function(src, "_sessionListQueryString") script = f""" global.window = {{ _showCliSessions: true }}; global._sessionSourceFilter = 'cli'; global._showAllProfiles = true; global._showArchived = false; +{requested_source_fn} {query_fn} const first = _sessionListQueryString(); window._showCliSessions = false; @@ -311,10 +314,12 @@ console.log(JSON.stringify({{ @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_apply_payload_and_tab_count_helpers_cover_old_and_new_payloads(): src = SESSIONS_JS.read_text(encoding="utf-8") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") apply_fn = _extract_function(src, "_applySessionListPayload") count_fn = _extract_function(src, "_sessionSourceTabCount") clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") script = f""" +global.window = {{ _showCliSessions: true }}; global._otherProfileCount = 0; global._archivedWebuiCount = 0; global._archivedCliCount = 0; @@ -331,6 +336,7 @@ global._sessionListHasLoadedOnce = false; global._sessionListFirstRenderAnimated = true; global._sessionListSkeletonActive = true; global._showAllProfiles = false; +global._sessionSourceFilter = 'webui'; global.S = {{ activeProfile: 'default' }}; global._reconcileActiveSessionIdleStateFromList = rows => rows; global._mergeOptimisticFirstTurnSessions = rows => rows; @@ -347,6 +353,7 @@ global.animateNextSessionListRefresh = () => {{}}; global.renderSessionListFromCache = () => {{}}; {clear_fn} {count_fn} +{requested_source_fn} {apply_fn} const sessions = [{{ session_id: 'webui-1' }}]; _applySessionListPayload({{ sessions, other_profile_count: 0, archived_count: 0, active_profile: 'default' }}, {{ projects: [] }}); @@ -375,6 +382,76 @@ console.log(JSON.stringify({{ oldPayload, newPayload }})); assert body["newPayload"] == {"webui": 11, "cli": 5} +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_source_filtered_cache_preserves_hidden_bucket_runtime_state(): + src = SESSIONS_JS.read_text(encoding="utf-8") + is_cli_fn = _extract_function(src, "_isCliSession") + purge_fn = _extract_function(src, "_purgeStaleInflightEntries") + mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions") + script = f""" +global._allSessions = [{{ + session_id: 'cli-1', + source_tag: 'cli', + raw_source: 'cli', + session_source: 'cli', + is_streaming: false, + message_count: 1, + last_message_at: 10, +}}]; +global._allSessionsScope = {{ sidebarSource: 'cli' }}; +global._sessionListSourceById = new Map([ + ['webui-live', 'webui'], + ['cli-stale', 'cli'], +]); +global._sessionStreamingById = new Map([ + ['webui-live', true], + ['cli-stale', true], +]); +global._sessionListSnapshotById = new Map([ + ['webui-live', {{ message_count: 1, last_message_at: 1 }}], + ['cli-stale', {{ message_count: 2, last_message_at: 2 }}], +]); +global._sendInProgress = false; +global._sendInProgressSid = null; +global.INFLIGHT = {{ + 'webui-live': {{ lastAssistantText: 'working' }}, + 'cli-stale': {{ lastAssistantText: 'stale' }}, +}}; +const cleared = []; +global.clearInflightState = sid => cleared.push(sid); +global._isSessionEffectivelyStreaming = s => Boolean(s.is_streaming); +global._getSessionObservedStreaming = () => ({{}}); +global._hasPendingUserMessageSignal = () => false; +global._isSessionActivelyViewedForList = () => false; +global._markSessionCompletionUnread = () => {{}}; +global._setSessionViewedCount = () => {{}}; +global._rememberObservedStreamingSession = () => {{}}; +global._forgetObservedStreamingSession = () => {{}}; +{is_cli_fn} +{purge_fn} +{mark_fn} +_purgeStaleInflightEntries(); +_markPollingCompletionUnreadTransitions(global._allSessions); +console.log(JSON.stringify({{ + inflightKeys: Object.keys(INFLIGHT), + cleared, + streamingKeys: Array.from(_sessionStreamingById.keys()).sort(), + snapshotKeys: Array.from(_sessionListSnapshotById.keys()).sort(), + sourceKeys: Array.from(_sessionListSourceById.keys()).sort(), +}})); +""" + body = _run_node(script) + + assert body["inflightKeys"] == ["webui-live"] + assert body["cleared"] == ["cli-stale"] + assert "webui-live" in body["streamingKeys"] + assert "cli-stale" not in body["streamingKeys"] + assert "webui-live" in body["snapshotKeys"] + assert "cli-stale" not in body["snapshotKeys"] + assert "webui-live" in body["sourceKeys"] + assert "cli-stale" not in body["sourceKeys"] + + def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) From efb784730a4d9503c7173dba48399f5523434605 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:51:12 -0400 Subject: [PATCH 17/45] Keep sidebar source fallbacks honest under refresh failure --- static/sessions.js | 30 +++- .../test_issue4766_sidebar_source_pushdown.py | 132 +++++++++++++++--- 2 files changed, 142 insertions(+), 20 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index d43a9e63..7d1e298f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -482,8 +482,28 @@ function _purgeStaleInflightEntries() { } } +function _rememberSessionListSource(s, sid = null) { + const resolvedSid = sid || (s && s.session_id); + if (!resolvedSid) return; + let source = null; + if (s && typeof _isCliSession === 'function') { + source = _isCliSession(s) ? 'cli' : 'webui'; + } + if (!source && Array.isArray(_allSessions)) { + const cached = _allSessions.find(item => item && item.session_id === resolvedSid); + if (cached && typeof _isCliSession === 'function') { + source = _isCliSession(cached) ? 'cli' : 'webui'; + } + } + if (!source && _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string') { + source = _allSessionsScope.sidebarSource; + } + if (source) _sessionListSourceById.set(resolvedSid, source); +} + function _rememberRenderedStreamingState(s, isStreaming) { if (!s || !s.session_id || !isStreaming) return; + _rememberSessionListSource(s); _sessionStreamingById.set(s.session_id, true); _rememberObservedStreamingSession(s); } @@ -588,6 +608,7 @@ function _renderRuntimeJournalAnchorActivityScene(activeStreamId, sid){ function _rememberRenderedSessionSnapshot(s) { if (!s || !s.session_id) return; + _rememberSessionListSource(s); const previous = _sessionListSnapshotById.get(s.session_id); if (previous) return; _sessionListSnapshotById.set(s.session_id, { @@ -622,6 +643,7 @@ function _markSessionCompletedInList(session, previousSid = null) { pending_started_at: null, is_streaming: false, }; + _rememberSessionListSource(_allSessions[idx], finalSid); _sessionStreamingById.set(finalSid, false); _forgetObservedStreamingSession(finalSid); if (previousSid && previousSid !== finalSid) { @@ -652,7 +674,7 @@ function _markPollingCompletionUnreadTransitions(sessions) { if (!s || !s.session_id) continue; const sid = s.session_id; seen.add(sid); - _sessionListSourceById.set(sid, _isCliSession(s) ? 'cli' : 'webui'); + _rememberSessionListSource(s, sid); const wasStreaming = _sessionStreamingById.get(sid); const isStreaming = _isSessionEffectivelyStreaming(s); const previousSnapshot = _sessionListSnapshotById.get(sid); @@ -3670,6 +3692,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){ function _dropStaleOptimisticSessionRow(sid){ if(!sid) return; + _rememberSessionListSource(null, sid); if(INFLIGHT&&INFLIGHT[sid]){ delete INFLIGHT[sid]; if(typeof clearInflightState==='function') clearInflightState(sid); @@ -3928,10 +3951,12 @@ async function _runRenderSessionListRefresh(opts, _gen){ const _curScope = { profile: S.activeProfile || 'default', allProfiles: !!_showAllProfiles, + sidebarSource: _requestedSessionSidebarSource(), }; const _scopeMatches = _allSessionsScope && _allSessionsScope.profile === _curScope.profile - && _allSessionsScope.allProfiles === _curScope.allProfiles; + && _allSessionsScope.allProfiles === _curScope.allProfiles + && _allSessionsScope.sidebarSource === _curScope.sidebarSource; // #4671: the /api/sessions fetch failed — clear the skeleton flag so this error // render (matched cache, or empty rows for a mismatched scope) replaces the // up-front profile-switch skeleton instead of stranding it. @@ -5325,6 +5350,7 @@ function _ensureActiveSessionRowPresent(rows, sourceRows){ function clearOptimisticSessionStreaming(sid){ sid=sid||(S.session&&S.session.session_id)||''; if(!sid) return; + _rememberSessionListSource(null, sid); if(S.session&&S.session.session_id===sid){ S.session.active_stream_id=null; S.activeStreamId=null; diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index f900dbcf..370abbce 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -386,6 +386,9 @@ console.log(JSON.stringify({{ oldPayload, newPayload }})); def test_source_filtered_cache_preserves_hidden_bucket_runtime_state(): src = SESSIONS_JS.read_text(encoding="utf-8") is_cli_fn = _extract_function(src, "_isCliSession") + remember_source_fn = _extract_function(src, "_rememberSessionListSource") + remember_streaming_fn = _extract_function(src, "_rememberRenderedStreamingState") + remember_snapshot_fn = _extract_function(src, "_rememberRenderedSessionSnapshot") purge_fn = _extract_function(src, "_purgeStaleInflightEntries") mark_fn = _extract_function(src, "_markPollingCompletionUnreadTransitions") script = f""" @@ -399,24 +402,12 @@ global._allSessions = [{{ last_message_at: 10, }}]; global._allSessionsScope = {{ sidebarSource: 'cli' }}; -global._sessionListSourceById = new Map([ - ['webui-live', 'webui'], - ['cli-stale', 'cli'], -]); -global._sessionStreamingById = new Map([ - ['webui-live', true], - ['cli-stale', true], -]); -global._sessionListSnapshotById = new Map([ - ['webui-live', {{ message_count: 1, last_message_at: 1 }}], - ['cli-stale', {{ message_count: 2, last_message_at: 2 }}], -]); +global._sessionListSourceById = new Map([['webui-live', 'webui']]); +global._sessionStreamingById = new Map([['webui-live', true]]); +global._sessionListSnapshotById = new Map([['webui-live', {{ message_count: 1, last_message_at: 1 }}]]); global._sendInProgress = false; global._sendInProgressSid = null; -global.INFLIGHT = {{ - 'webui-live': {{ lastAssistantText: 'working' }}, - 'cli-stale': {{ lastAssistantText: 'stale' }}, -}}; +global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }}; const cleared = []; global.clearInflightState = sid => cleared.push(sid); global._isSessionEffectivelyStreaming = s => Boolean(s.is_streaming); @@ -428,8 +419,23 @@ global._setSessionViewedCount = () => {{}}; global._rememberObservedStreamingSession = () => {{}}; global._forgetObservedStreamingSession = () => {{}}; {is_cli_fn} +{remember_source_fn} +{remember_streaming_fn} +{remember_snapshot_fn} {purge_fn} {mark_fn} +const cliStale = {{ + session_id: 'cli-stale', + source_tag: 'cli', + raw_source: 'cli', + session_source: 'cli', + is_streaming: false, + message_count: 2, + last_message_at: 2, +}}; +_rememberRenderedStreamingState(cliStale, true); +_rememberRenderedSessionSnapshot(cliStale); +INFLIGHT['cli-stale'] = {{ lastAssistantText: 'stale' }}; _purgeStaleInflightEntries(); _markPollingCompletionUnreadTransitions(global._allSessions); console.log(JSON.stringify({{ @@ -475,10 +481,100 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): assert body["sessions"][0]["session_id"] == "webui-1" -def test_scope_mismatch_error_path_clears_server_tab_counts(): +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_scope_mismatch_error_path_respects_sidebar_source(): src = SESSIONS_JS.read_text(encoding="utf-8") + clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + query_fn = _extract_function(src, "_sessionListQueryString") + refresh_fn = _extract_function(src, "_runRenderSessionListRefresh").replace( + "function _runRenderSessionListRefresh", + "async function _runRenderSessionListRefresh", + 1, + ) + script = f""" +global.window = {{ _showCliSessions: true }}; +global._showAllProfiles = false; +global._showArchived = false; +global._sessionListHasLoadedOnce = true; +global._SESSION_LIST_BOOT_TIMEOUT_MS = 90000; +global._renderSessionListGen = 1; +global._profileSwitchListEmbargo = false; +global._pendingSessionListPayload = null; +global._allProjects = []; +global._contentSearchResults = ['stale']; +global.S = {{ activeProfile: 'default' }}; +global.$ = () => ({{ value: '' }}); +global._isSessionListUserInteracting = () => false; +global._schedulePendingSessionListApply = () => {{}}; +global._showSessionListLoadError = error => {{ + global._lastError = error.message; +}}; +const renders = []; +global.renderSessionListFromCache = () => {{ + renders.push({{ + sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null, + scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null, + webui: global._serverWebuiSessionCount, + cli: global._serverCliSessionCount, + skeleton: global._sessionListSkeletonActive, + }}); +}}; +global.api = () => Promise.reject(new Error('boom')); +{clear_fn} +{requested_source_fn} +{query_fn} +{refresh_fn} +async function runCase(requestedSource, cachedSource) {{ + global._sessionSourceFilter = requestedSource; + global._allSessions = [{{ session_id: cachedSource + '-1' }}]; + global._allSessionsScope = {{ + profile: 'default', + allProfiles: false, + sidebarSource: cachedSource, + }}; + global._serverWebuiSessionCount = 11; + global._serverCliSessionCount = 5; + global._sessionListSkeletonActive = true; + global._lastError = null; + renders.length = 0; + await _runRenderSessionListRefresh({{}}, 1); + return {{ + sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null, + scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null, + webui: global._serverWebuiSessionCount, + cli: global._serverCliSessionCount, + skeleton: global._sessionListSkeletonActive, + error: global._lastError, + render: renders[0] || null, + }}; +}} +(async () => {{ + const mismatch = await runCase('cli', 'webui'); + const match = await runCase('webui', 'webui'); + console.log(JSON.stringify({{ mismatch, match }})); +}})().catch(error => {{ + console.error(error); + process.exit(1); +}}); +""" + body = _run_node(script) - assert "_clearSessionSourceTabCounts();" in src + assert body["mismatch"]["sessions"] == [] + assert body["mismatch"]["scope"] is None + assert body["mismatch"]["webui"] is None + assert body["mismatch"]["cli"] is None + assert body["mismatch"]["skeleton"] is False + assert body["mismatch"]["render"]["sessions"] == [] + assert body["match"]["sessions"] == ["webui-1"] + assert body["match"]["scope"] == { + "profile": "default", + "allProfiles": False, + "sidebarSource": "webui", + } + assert body["match"]["webui"] == 11 + assert body["match"]["cli"] == 5 + assert body["match"]["render"]["sessions"] == ["webui-1"] def test_payload_row_count_regression(monkeypatch): From 742a19ab709489611702eff4f5e542ccb4ddc189 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 06:58:53 -0400 Subject: [PATCH 18/45] Keep sidebar partition proofs aligned with helper counts --- tests/test_sidebar_session_partition.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_sidebar_session_partition.py b/tests/test_sidebar_session_partition.py index a1afa4f1..35cbdac5 100644 --- a/tests/test_sidebar_session_partition.py +++ b/tests/test_sidebar_session_partition.py @@ -32,11 +32,12 @@ def test_render_uses_single_pass_partition_helper(): assert "_partitionSidebarSessionRows(allMatched, activeSidForSidebar)" in render_body assert "_renderSidebarRowsFromRawSessions(sessionsRaw, referenceRaw)" in render_body - assert "? sessions.length" in render_body - assert ": _renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;" in render_body - assert ": _renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;" in render_body - assert "const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;" in render_body - assert "const count=filter==='cli'?cliSessionCount:webuiSessionCount;" not in render_body + assert "const renderedWebuiSessionCount=_renderSidebarRowsFromRawSessions(webuiSessionsRaw, webuiReferenceRaw).length;" in render_body + assert "const renderedCliSessionCount=_renderSidebarRowsFromRawSessions(cliSessionsRaw, cliReferenceRaw).length;" in render_body + assert "const webuiSessionTabCount=_sessionSourceTabCount('webui', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body + assert "const cliSessionTabCount=_sessionSourceTabCount('cli', renderedWebuiSessionCount, renderedCliSessionCount);" in render_body + assert "const count=filter==='cli'?cliSessionTabCount:webuiSessionTabCount;" in render_body + assert "const count=filter==='cli'?renderedCliSessionCount:renderedWebuiSessionCount;" not in render_body assert "withMessages.filter(" not in render_body From f5f8f4800610086045a98715d6b4ee05f133fbb5 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:06:07 -0400 Subject: [PATCH 19/45] Keep hidden source state through failed source refreshes --- static/sessions.js | 2 +- .../test_issue4766_sidebar_source_pushdown.py | 20 ++++++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 7d1e298f..7aaa51ba 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3965,7 +3965,7 @@ async function _runRenderSessionListRefresh(opts, _gen){ renderSessionListFromCache(); } else { _allSessions = []; - _allSessionsScope = null; + _allSessionsScope = _curScope; _clearSessionSourceTabCounts(); renderSessionListFromCache(); } diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 370abbce..3f18a5a7 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -484,6 +484,7 @@ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_scope_mismatch_error_path_respects_sidebar_source(): src = SESSIONS_JS.read_text(encoding="utf-8") + purge_fn = _extract_function(src, "_purgeStaleInflightEntries") clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") query_fn = _extract_function(src, "_sessionListQueryString") @@ -511,16 +512,21 @@ global._showSessionListLoadError = error => {{ global._lastError = error.message; }}; const renders = []; +const cleared = []; global.renderSessionListFromCache = () => {{ + _purgeStaleInflightEntries(); renders.push({{ sessions: Array.isArray(global._allSessions) ? global._allSessions.map(s => s.session_id) : null, scope: global._allSessionsScope ? {{ ...global._allSessionsScope }} : null, webui: global._serverWebuiSessionCount, cli: global._serverCliSessionCount, skeleton: global._sessionListSkeletonActive, + inflightKeys: Object.keys(global.INFLIGHT || {{}}).sort(), }}); }}; global.api = () => Promise.reject(new Error('boom')); +global.clearInflightState = sid => cleared.push(sid); +{purge_fn} {clear_fn} {requested_source_fn} {query_fn} @@ -533,6 +539,9 @@ async function runCase(requestedSource, cachedSource) {{ allProfiles: false, sidebarSource: cachedSource, }}; + global._sessionListSourceById = new Map([['webui-live', 'webui']]); + global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }}; + cleared.length = 0; global._serverWebuiSessionCount = 11; global._serverCliSessionCount = 5; global._sessionListSkeletonActive = true; @@ -546,6 +555,8 @@ async function runCase(requestedSource, cachedSource) {{ cli: global._serverCliSessionCount, skeleton: global._sessionListSkeletonActive, error: global._lastError, + cleared: [...cleared], + inflightKeys: Object.keys(global.INFLIGHT || {{}}).sort(), render: renders[0] || null, }}; }} @@ -561,11 +572,18 @@ async function runCase(requestedSource, cachedSource) {{ body = _run_node(script) assert body["mismatch"]["sessions"] == [] - assert body["mismatch"]["scope"] is None + assert body["mismatch"]["scope"] == { + "profile": "default", + "allProfiles": False, + "sidebarSource": "cli", + } assert body["mismatch"]["webui"] is None assert body["mismatch"]["cli"] is None assert body["mismatch"]["skeleton"] is False assert body["mismatch"]["render"]["sessions"] == [] + assert body["mismatch"]["inflightKeys"] == ["webui-live"] + assert body["mismatch"]["cleared"] == [] + assert body["mismatch"]["render"]["inflightKeys"] == ["webui-live"] assert body["match"]["sessions"] == ["webui-1"] assert body["match"]["scope"] == { "profile": "default", From 0f5e0d71b7121a4e82641e45b63cf4d771a8776a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:37:42 -0400 Subject: [PATCH 20/45] Keep sidebar state helpers compatible with isolated JS harnesses --- static/sessions.js | 52 ++++++++++++++----- ...ssue4662_profile_switch_skeleton_static.py | 16 ++++-- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 7aaa51ba..ac172c10 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -450,7 +450,14 @@ function _purgeStaleInflightEntries() { if (s && s.session_id) sessionsById.set(s.session_id, s); } } - const currentSidebarSource = _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string' + const sourceById = typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.get === 'function' + ? _sessionListSourceById + : null; + const currentSidebarSource = typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string' ? _allSessionsScope.sidebarSource : null; for (const sid of Object.keys(INFLIGHT)) { @@ -462,7 +469,7 @@ function _purgeStaleInflightEntries() { continue; } if (!sessionsById.has(sid)) { - const knownSource = _sessionListSourceById.get(sid); + const knownSource = sourceById ? sourceById.get(sid) : null; if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) { continue; } @@ -495,15 +502,23 @@ function _rememberSessionListSource(s, sid = null) { source = _isCliSession(cached) ? 'cli' : 'webui'; } } - if (!source && _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string') { + if (!source + && typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string') { source = _allSessionsScope.sidebarSource; } - if (source) _sessionListSourceById.set(resolvedSid, source); + if (source + && typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.set === 'function') { + _sessionListSourceById.set(resolvedSid, source); + } } function _rememberRenderedStreamingState(s, isStreaming) { if (!s || !s.session_id || !isStreaming) return; - _rememberSessionListSource(s); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s); _sessionStreamingById.set(s.session_id, true); _rememberObservedStreamingSession(s); } @@ -608,7 +623,7 @@ function _renderRuntimeJournalAnchorActivityScene(activeStreamId, sid){ function _rememberRenderedSessionSnapshot(s) { if (!s || !s.session_id) return; - _rememberSessionListSource(s); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s); const previous = _sessionListSnapshotById.get(s.session_id); if (previous) return; _sessionListSnapshotById.set(s.session_id, { @@ -643,7 +658,7 @@ function _markSessionCompletedInList(session, previousSid = null) { pending_started_at: null, is_streaming: false, }; - _rememberSessionListSource(_allSessions[idx], finalSid); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(_allSessions[idx], finalSid); _sessionStreamingById.set(finalSid, false); _forgetObservedStreamingSession(finalSid); if (previousSid && previousSid !== finalSid) { @@ -667,14 +682,23 @@ function _markSessionCompletedInList(session, previousSid = null) { function _markPollingCompletionUnreadTransitions(sessions) { if (!Array.isArray(sessions)) return; const seen = new Set(); - const currentSidebarSource = _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string' + const sourceById = typeof _sessionListSourceById !== 'undefined' + && _sessionListSourceById + && typeof _sessionListSourceById.get === 'function' + && typeof _sessionListSourceById.keys === 'function' + && typeof _sessionListSourceById.delete === 'function' + ? _sessionListSourceById + : new Map(); + const currentSidebarSource = typeof _allSessionsScope !== 'undefined' + && _allSessionsScope + && typeof _allSessionsScope.sidebarSource === 'string' ? _allSessionsScope.sidebarSource : null; for (const s of sessions) { if (!s || !s.session_id) continue; const sid = s.session_id; seen.add(sid); - _rememberSessionListSource(s, sid); + if (typeof _rememberSessionListSource === 'function') _rememberSessionListSource(s, sid); const wasStreaming = _sessionStreamingById.get(sid); const isStreaming = _isSessionEffectivelyStreaming(s); const previousSnapshot = _sessionListSnapshotById.get(sid); @@ -715,15 +739,15 @@ function _markPollingCompletionUnreadTransitions(sessions) { const staleRuntimeStateSids = new Set([ ...Array.from(_sessionStreamingById.keys()), ...Array.from(_sessionListSnapshotById.keys()), - ...Array.from(_sessionListSourceById.keys()), + ...Array.from(sourceById.keys()), ]); for (const sid of staleRuntimeStateSids) { if (seen.has(sid)) continue; - const knownSource = _sessionListSourceById.get(sid); + const knownSource = sourceById.get(sid); if (currentSidebarSource && (!knownSource || knownSource !== currentSidebarSource)) continue; _sessionStreamingById.delete(sid); _sessionListSnapshotById.delete(sid); - _sessionListSourceById.delete(sid); + sourceById.delete(sid); } } @@ -3692,7 +3716,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){ function _dropStaleOptimisticSessionRow(sid){ if(!sid) return; - _rememberSessionListSource(null, sid); + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid); if(INFLIGHT&&INFLIGHT[sid]){ delete INFLIGHT[sid]; if(typeof clearInflightState==='function') clearInflightState(sid); @@ -5350,7 +5374,7 @@ function _ensureActiveSessionRowPresent(rows, sourceRows){ function clearOptimisticSessionStreaming(sid){ sid=sid||(S.session&&S.session.session_id)||''; if(!sid) return; - _rememberSessionListSource(null, sid); + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid); if(S.session&&S.session.session_id===sid){ S.session.active_stream_id=null; S.activeStreamId=null; diff --git a/tests/test_issue4662_profile_switch_skeleton_static.py b/tests/test_issue4662_profile_switch_skeleton_static.py index c7b6f97b..90eb0a96 100644 --- a/tests/test_issue4662_profile_switch_skeleton_static.py +++ b/tests/test_issue4662_profile_switch_skeleton_static.py @@ -133,11 +133,17 @@ class TestSwitchWiring: class TestSessionsWiring: def test_skeleton_flag_cleared_on_real_render(self): - # renderSessionListFromCache clears the skeleton-active flag when it - # writes real rows (so a strand can't persist). - idx = SESSIONS.index("function renderSessionListFromCache(") - body = SESSIONS[idx: idx + 4000] - assert "_sessionListSkeletonActive=false" in body.replace(" ", "") + # The authoritative clear now happens in _applySessionListPayload() + # immediately before the real render, while renderSessionListFromCache() + # keeps the guard that blocks stale cached rows during the skeleton phase. + render_idx = SESSIONS.index("function renderSessionListFromCache(") + render_body = SESSIONS[render_idx: render_idx + 1200] + apply_idx = SESSIONS.index("function _applySessionListPayload(") + apply_body = SESSIONS[apply_idx: apply_idx + 4000] + + assert "if(_sessionListSkeletonActive)return;" in render_body.replace(" ", "") + assert "_sessionListSkeletonActive = false;" in apply_body + assert "renderSessionListFromCache();" in apply_body def test_builder_defines_groups_and_function(self): assert "const _SESSION_SKELETON_GROUPS" in SESSIONS From 52557d113785f37257993ef82f05622832300495 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:44:03 -0400 Subject: [PATCH 21/45] Keep isolated sidebar dedupe harnesses aligned with source-map cleanup --- tests/test_sidebar_completion_dedupe.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_sidebar_completion_dedupe.py b/tests/test_sidebar_completion_dedupe.py index 82d26c44..436bb29e 100644 --- a/tests/test_sidebar_completion_dedupe.py +++ b/tests/test_sidebar_completion_dedupe.py @@ -31,6 +31,7 @@ let _allSessions = [ ]; let _sessionStreamingById = new Map([['old', true], ['new', true]]); let _sessionListSnapshotById = new Map([['old', {{message_count: 10}}]]); +let _sessionListSourceById = new Map([['old', 'webui'], ['new', 'webui']]); function _forgetObservedStreamingSession(_sid) {{}} function renderSessionListFromCache() {{}} {fn_src} @@ -40,7 +41,11 @@ _markSessionCompletedInList({{ message_count: 3, parent_session_id: 'old', }}, 'old'); -console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreamingById.has('old')}})); +console.log(JSON.stringify({{ + rows: _allSessions, + oldStreaming: _sessionStreamingById.has('old'), + oldSourceTracked: _sessionListSourceById.has('old'), +}})); """ result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True) payload = json.loads(result.stdout) @@ -49,3 +54,4 @@ console.log(JSON.stringify({{rows: _allSessions, oldStreaming: _sessionStreaming assert payload["rows"][0]["title"] == "After done" assert payload["rows"][0]["message_count"] == 3 assert payload["oldStreaming"] is False + assert payload["oldSourceTracked"] is False From df4cdf7f9ba06e58d511e45d95b5dfb7f75feb84 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:49:46 -0400 Subject: [PATCH 22/45] Prevent stale cross-bucket rows during source-tab switches --- static/sessions.js | 1 + tests/test_issue4766_sidebar_source_pushdown.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/static/sessions.js b/static/sessions.js index ac172c10..72a6aa46 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1658,6 +1658,7 @@ function _setSessionSourceFilter(filter) { _selectedSessions.clear(); _sessionSelectMode = false; try { localStorage.setItem('hermes-session-source-filter', next); } catch (_e) {} + renderSessionListFromCache(); void renderSessionList({deferWhileInteracting:false}); } diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 3f18a5a7..869bec5c 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -286,6 +286,9 @@ global.localStorage = {{ this.writes.push([key, value]); }}, }}; +global.renderSessionListFromCache = () => {{ + renderCalls.push('cache'); +}}; global.renderSessionList = (opts) => {{ renderCalls.push(opts); return Promise.resolve(); @@ -308,7 +311,7 @@ console.log(JSON.stringify({{ assert body["selectedSize"] == 0 assert body["sessionSelectMode"] is False assert body["storageWrites"] == [["hermes-session-source-filter", "cli"]] - assert body["renderCalls"] == [{"deferWhileInteracting": False}] + assert body["renderCalls"] == ["cache", {"deferWhileInteracting": False}] @pytest.mark.skipif(NODE is None, reason="node not on PATH") From 148a1bfab2b0ce612bc5442db82fb7f8548d7476 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 07:58:55 -0400 Subject: [PATCH 23/45] Keep source-bucket cleanup tied to real session origin --- static/sessions.js | 9 ++++---- .../test_issue4766_sidebar_source_pushdown.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 72a6aa46..f01741bc 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -489,7 +489,7 @@ function _purgeStaleInflightEntries() { } } -function _rememberSessionListSource(s, sid = null) { +function _rememberSessionListSource(s, sid = null, allowScopeFallback = true) { const resolvedSid = sid || (s && s.session_id); if (!resolvedSid) return; let source = null; @@ -503,6 +503,7 @@ function _rememberSessionListSource(s, sid = null) { } } if (!source + && allowScopeFallback && typeof _allSessionsScope !== 'undefined' && _allSessionsScope && typeof _allSessionsScope.sidebarSource === 'string') { @@ -1641,7 +1642,7 @@ function _sessionListQueryString() { qs.set('sidebar_source', _requestedSessionSidebarSource()); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); - return qs.toString() ? `?${qs.toString()}` : ''; + return `?${qs.toString()}`; } function _sessionSourceTabCount(filter, renderedWebuiSessionCount, renderedCliSessionCount) { @@ -3717,7 +3718,7 @@ function _shouldKeepLocalOnlyOptimisticSessionRow(local){ function _dropStaleOptimisticSessionRow(sid){ if(!sid) return; - if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid); + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false); if(INFLIGHT&&INFLIGHT[sid]){ delete INFLIGHT[sid]; if(typeof clearInflightState==='function') clearInflightState(sid); @@ -5375,7 +5376,7 @@ function _ensureActiveSessionRowPresent(rows, sourceRows){ function clearOptimisticSessionStreaming(sid){ sid=sid||(S.session&&S.session.session_id)||''; if(!sid) return; - if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid); + if(typeof _rememberSessionListSource==='function') _rememberSessionListSource(null, sid, false); if(S.session&&S.session.session_id===sid){ S.session.active_stream_id=null; S.activeStreamId=null; diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 869bec5c..46b36c20 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -461,6 +461,29 @@ console.log(JSON.stringify({{ assert "cli-stale" not in body["sourceKeys"] +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_sid_only_source_remembering_skips_scope_fallback(): + src = SESSIONS_JS.read_text(encoding="utf-8") + is_cli_fn = _extract_function(src, "_isCliSession") + remember_source_fn = _extract_function(src, "_rememberSessionListSource") + script = f""" +global._allSessions = []; +global._allSessionsScope = {{ sidebarSource: 'cli' }}; +global._sessionListSourceById = new Map(); +{is_cli_fn} +{remember_source_fn} +_rememberSessionListSource(null, 'detached-sid', false); +console.log(JSON.stringify({{ + hasDetached: _sessionListSourceById.has('detached-sid'), + remembered: Array.from(_sessionListSourceById.entries()), +}})); +""" + body = _run_node(script) + + assert body["hasDetached"] is False + assert body["remembered"] == [] + + def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) From 49e0f8447484a1a9ed59b3a73aa7ca436a3d41a5 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 12:19:34 +0000 Subject: [PATCH 24/45] =?UTF-8?q?Release=20v0.51.602=20=E2=80=94=20Release?= =?UTF-8?q?=20VI=20(sidebar=20fetches=20only=20active=20source=20bucket,?= =?UTF-8?q?=20#4766/#4769)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2652ead6..e74a3b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.602] — 2026-06-23 — Release VI (sidebar fetches only the active source bucket) + +### Changed + +- **The sidebar now fetches only the active WebUI/CLI tab's sessions instead of pulling the full mixed list and discarding the inactive half in the browser.** The active tab is sent as a `sidebar_source` query param, the `/api/sessions` payload is filtered server-side before serialization (keyed distinctly per bucket in the list cache), and explicit per-bucket counts are returned so both tab labels stay accurate. On a tab switch the sidebar now repaints synchronously under the new bucket so stale cross-bucket rows can't linger. Thanks @rodboev. (#4766) + ## [v0.51.601] — 2026-06-23 — Release VH (sidebar lineage rows keep running/unread state when continuation is hidden) ### Fixed From c6994b501a68887e812b0d0b5f483bea9692c986 Mon Sep 17 00:00:00 2001 From: boriken72 Date: Tue, 23 Jun 2026 08:59:01 -0400 Subject: [PATCH 25/45] perf: cache app-shell template instead of re-reading index.html per request The `/`, `/index.html`, and `/session/` routes are the hottest navigations. Each request re-read the ~190 KB static/index.html from disk and re-applied two process-constant token substitutions (__WEBUI_VERSION__, __MAX_UPLOAD_BYTES__), then ran inject_extension_tags() (which itself reads the extension state + manifest from disk). WEBUI_VERSION and MAX_UPLOAD_BYTES are fixed for the process lifetime, so this caches the partially rendered template in an in-process cache keyed by (size, mtime_ns) -- mirroring the existing _STATIC_CACHE pattern in this file -- so a redeploy is still picked up without a restart. The per-session CSRF token and runtime extension tags are still applied per request, so output is byte-identical to before. Verified: byte-identical to the old inline render, CSRF still varies per request, cache invalidates on mtime change, ~5x faster shell render in a local microbenchmark. Adds tests/test_index_shell_template_cache.py. --- CHANGELOG.md | 4 ++ api/routes.py | 55 ++++++++++++--- tests/test_index_shell_template_cache.py | 87 ++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 tests/test_index_shell_template_cache.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e74a3b4e..cb08b524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Changed + +- **The app shell (`/`, `/index.html`, `/session/`) is now served from an in-process template cache instead of re-reading and re-rendering `static/index.html` (~190 KB) on every navigation.** These are the hottest routes; each request previously re-read the file from disk and re-applied two process-constant token substitutions (`__WEBUI_VERSION__`, `__MAX_UPLOAD_BYTES__`). The partially rendered template is now cached and invalidated on `(size, mtime_ns)` change — mirroring the existing static-asset cache — so a redeploy is still picked up without a restart. The per-session CSRF token and runtime extension tags are still applied per request, so output is byte-identical to before. Measured ~5× faster shell render in a local microbenchmark and removes per-request extension-manifest disk reads. + ## [v0.51.602] — 2026-06-23 — Release VI (sidebar fetches only the active source bucket) ### Changed diff --git a/api/routes.py b/api/routes.py index 019fbf1c..31c6ac34 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8849,6 +8849,48 @@ def _save_saved_prompts(prompts: list) -> None: p.write_text(json.dumps(prompts, ensure_ascii=False, indent=2), encoding="utf-8") +# In-process cache for the app-shell template. The `/`, `/index.html`, and +# `/session/` routes are the hottest navigations and each re-read the +# ~190 KB static/index.html from disk and re-ran the two process-constant +# substitutions (__WEBUI_VERSION__, __MAX_UPLOAD_BYTES__) on every request. +# Those values are fixed for the process lifetime, so we cache the partially +# rendered template here, keyed by (size, nanosecond mtime) exactly like +# _STATIC_CACHE so a redeploy is picked up without a restart. The two values +# that genuinely vary per request — the per-session CSRF token and the runtime +# extension tags (inject_extension_tags) — are still applied on each request +# against the cached base, so caching changes no observable output. +_INDEX_SHELL_CACHE: dict = {} +_INDEX_SHELL_CACHE_LOCK = threading.Lock() + + +def _render_index_shell_base() -> str: + """Return static/index.html with the process-constant tokens substituted. + + Cached and invalidated on (size, mtime_ns) change. The CSRF token and + extension-tag injection are intentionally NOT applied here — they vary per + request and are applied by the caller against this base string. + """ + from api.updates import WEBUI_VERSION + + st = _INDEX_HTML_PATH.stat() + sig = (st.st_size, st.st_mtime_ns) + with _INDEX_SHELL_CACHE_LOCK: + cached = _INDEX_SHELL_CACHE.get("base") + if cached and cached[0] == sig: + return cached[1] + from urllib.parse import quote + + version_token = quote(WEBUI_VERSION, safe="") + base = ( + _INDEX_HTML_PATH.read_text(encoding="utf-8") + .replace("__WEBUI_VERSION__", version_token) + .replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES)) + ) + with _INDEX_SHELL_CACHE_LOCK: + _INDEX_SHELL_CACHE["base"] = (sig, base) + return base + + def handle_get(handler, parsed) -> bool: """Handle all GET routes. Returns True if handled, False for 404.""" @@ -8870,9 +8912,6 @@ def handle_get(handler, parsed) -> bool: if parsed.path in ("/", "/index.html") or parsed.path.startswith("/session/"): try: - from urllib.parse import quote - from api.updates import WEBUI_VERSION - version_token = quote(WEBUI_VERSION, safe="") from api.extensions import inject_extension_tags csrf_token = "" @@ -8886,11 +8925,11 @@ def handle_get(handler, parsed) -> bool: except Exception: csrf_token = "" - html = ( - _INDEX_HTML_PATH.read_text(encoding="utf-8") - .replace("__WEBUI_VERSION__", version_token) - .replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES)) - .replace("__CSRF_TOKEN_JSON__", json.dumps(csrf_token)) + # The disk read + process-constant token substitutions are cached; + # only the per-session CSRF token and per-request extension tags are + # applied here (see _render_index_shell_base). + html = _render_index_shell_base().replace( + "__CSRF_TOKEN_JSON__", json.dumps(csrf_token) ) return t( handler, diff --git a/tests/test_index_shell_template_cache.py b/tests/test_index_shell_template_cache.py new file mode 100644 index 00000000..de279a55 --- /dev/null +++ b/tests/test_index_shell_template_cache.py @@ -0,0 +1,87 @@ +"""Regression tests for the app-shell template cache (_render_index_shell_base). + +The `/`, `/index.html`, and `/session/` routes are the hottest navigations. +Each previously re-read the ~190 KB static/index.html from disk and re-ran the +two process-constant token substitutions (__WEBUI_VERSION__, +__MAX_UPLOAD_BYTES__) on every request. _render_index_shell_base() caches the +partially rendered template, keyed by (size, mtime_ns) like _STATIC_CACHE, while +the per-session CSRF token and per-request extension tags stay per-request. + +These tests guard the load-bearing invariant: caching must change no observable +output, only avoid redundant work. +""" +from __future__ import annotations + +import json +import os +import time +from urllib.parse import quote + +import api.routes as routes +from api.updates import WEBUI_VERSION + + +def _old_inline_render(csrf_token: str) -> str: + """Reproduce the pre-cache inline render exactly, for equivalence checks.""" + return ( + routes._INDEX_HTML_PATH.read_text(encoding="utf-8") + .replace("__WEBUI_VERSION__", quote(WEBUI_VERSION, safe="")) + .replace("__MAX_UPLOAD_BYTES__", str(routes.MAX_UPLOAD_BYTES)) + .replace("__CSRF_TOKEN_JSON__", json.dumps(csrf_token)) + ) + + +def test_base_substitutes_process_constants_but_not_csrf(): + base = routes._render_index_shell_base() + assert "__WEBUI_VERSION__" not in base + assert "__MAX_UPLOAD_BYTES__" not in base + # CSRF must remain a placeholder — it varies per request and is applied by + # the caller, not baked into the shared cache. + assert "__CSRF_TOKEN_JSON__" in base + + +def test_cached_render_is_byte_identical_to_old_inline(): + csrf = "test-csrf-abc123" + new = routes._render_index_shell_base().replace( + "__CSRF_TOKEN_JSON__", json.dumps(csrf) + ) + assert new == _old_inline_render(csrf) + + +def test_csrf_token_varies_per_request(): + a = routes._render_index_shell_base().replace( + "__CSRF_TOKEN_JSON__", json.dumps("AAA") + ) + b = routes._render_index_shell_base().replace( + "__CSRF_TOKEN_JSON__", json.dumps("BBB") + ) + assert json.dumps("AAA") in a + assert json.dumps("BBB") in b + assert a != b + + +def test_second_call_returns_cached_object(): + # Warm the cache, then assert the identical object comes back (no re-read). + first = routes._render_index_shell_base() + second = routes._render_index_shell_base() + assert first is second + + +def test_cache_invalidates_on_mtime_change(tmp_path, monkeypatch): + # Point the module at a temp copy so we can mutate its mtime safely. + src = routes._INDEX_HTML_PATH.read_text(encoding="utf-8") + fake = tmp_path / "index.html" + fake.write_text(src, encoding="utf-8") + monkeypatch.setattr(routes, "_INDEX_HTML_PATH", fake) + # Reset the shared cache so this test is order-independent. + monkeypatch.setattr(routes, "_INDEX_SHELL_CACHE", {}) + + routes._render_index_shell_base() + sig_before = routes._INDEX_SHELL_CACHE["base"][0] + + future = time.time() + 5 + os.utime(fake, (future, future)) + routes._render_index_shell_base() + sig_after = routes._INDEX_SHELL_CACHE["base"][0] + + assert sig_after != sig_before From 9cc3f30d82eee1aee2565f2c42ceb34c56d8f57b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 13:27:40 +0000 Subject: [PATCH 26/45] test(#4774): point asset-version-encoding test at relocated shell-render helper #4774 moved the version-token substitution from the index route handler into the cached _render_index_shell_base() helper. quote(WEBUI_VERSION, safe='') encoding is intact (verified), just relocated, so the source-marker test scanning the route block no longer found it. Updated to check the helper. Maintainer test-fix on boriken72's PR. --- tests/test_pwa_manifest_sw.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index 0e10001f..380e110f 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -344,13 +344,24 @@ class TestIndexHtmlIntegration: def test_index_route_url_encodes_asset_version(self): src = ROUTES.read_text(encoding="utf-8") - idx = src.find('parsed.path in ("/", "/index.html")') - if idx == -1: - idx = src.find('parsed.path.startswith("/session/")') - assert idx != -1, "routes.py must handle /, /index.html, and /session/" - block = src[idx:idx + 800] + # #4774 moved the app-shell render (incl. the version-token substitution) + # out of the route handler and into the cached `_render_index_shell_base()` + # helper. The security property — the cache-busting version token is + # URL-encoded before it's injected into script src / SW registration — must + # still hold, so assert it in whichever location renders the shell. Check the + # shell-render helper first (its current home), then fall back to the route + # handler block for older layouts. + helper_idx = src.find("def _render_index_shell_base") + if helper_idx != -1: + block = src[helper_idx:helper_idx + 1200] + else: + idx = src.find('parsed.path in ("/", "/index.html")') + if idx == -1: + idx = src.find('parsed.path.startswith("/session/")') + assert idx != -1, "routes.py must handle /, /index.html, and /session/" + block = src[idx:idx + 800] assert "quote(WEBUI_VERSION, safe=\"\")" in block, ( - "index route must URL-encode the cache-busting version token before " + "the app-shell render must URL-encode the cache-busting version token before " "injecting it into script src attributes and service worker registration" ) From f3a509e2e9048125e7a5ae463f99e192ce9ed966 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 13:39:07 +0000 Subject: [PATCH 27/45] =?UTF-8?q?Release=20v0.51.603=20=E2=80=94=20Release?= =?UTF-8?q?=20VJ=20(cache=20app-shell=20template,=20#4774)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb08b524..47b5a2af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ ## [Unreleased] +## [v0.51.603] — 2026-06-23 — Release VJ (cache the app-shell template) + ### Changed -- **The app shell (`/`, `/index.html`, `/session/`) is now served from an in-process template cache instead of re-reading and re-rendering `static/index.html` (~190 KB) on every navigation.** These are the hottest routes; each request previously re-read the file from disk and re-applied two process-constant token substitutions (`__WEBUI_VERSION__`, `__MAX_UPLOAD_BYTES__`). The partially rendered template is now cached and invalidated on `(size, mtime_ns)` change — mirroring the existing static-asset cache — so a redeploy is still picked up without a restart. The per-session CSRF token and runtime extension tags are still applied per request, so output is byte-identical to before. Measured ~5× faster shell render in a local microbenchmark and removes per-request extension-manifest disk reads. +- **The app shell (`/`, `/index.html`, `/session/`) is now served from an in-process template cache instead of re-reading and re-rendering `static/index.html` (~190 KB) on every navigation.** These are the hottest routes; each request previously re-read the file from disk and re-applied two process-constant token substitutions (`__WEBUI_VERSION__`, `__MAX_UPLOAD_BYTES__`). The partially rendered template is now cached and invalidated on `(size, mtime_ns)` change — mirroring the existing static-asset cache — so a redeploy is still picked up without a restart. The per-session CSRF token and runtime extension tags are still applied per request, so output is byte-identical to before. Measured ~5× faster shell render in a local microbenchmark and removes per-request extension-manifest disk reads. Thanks @boriken72. (#4774) ## [v0.51.602] — 2026-06-23 — Release VI (sidebar fetches only the active source bucket) From e27b7cf04221b58d929995ca0a25b45506a0d3b0 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 10:29:06 -0400 Subject: [PATCH 28/45] perf(sidebar): trim hidden and empty sidebar payload rows (#4775) --- api/routes.py | 44 +++ static/sessions.js | 1 + .../test_issue4766_sidebar_source_pushdown.py | 13 +- ...75_sidebar_hidden_zero_message_pushdown.py | 298 ++++++++++++++++++ 4 files changed, 353 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py diff --git a/api/routes.py b/api/routes.py index 31c6ac34..bb63f4aa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1597,6 +1597,8 @@ def _session_list_cache_key( show_previous_messaging_sessions: bool, show_cron_sessions: bool, include_archived: bool = False, + exclude_hidden: bool = False, + visible_only: bool = False, source_filter: str | None = None, sidebar_source: str | None = None, ) -> tuple: @@ -1607,6 +1609,8 @@ def _session_list_cache_key( bool(show_previous_messaging_sessions), bool(show_cron_sessions), bool(include_archived), + bool(exclude_hidden), + bool(visible_only), source_filter, sidebar_source, ) @@ -1926,12 +1930,37 @@ def _build_session_list_cache_payload( show_previous_messaging_sessions: bool, show_cron_sessions: bool, include_archived: bool = False, + exclude_hidden: bool = False, + visible_only: bool = False, source_filter: str | None = None, sidebar_source: str | None = None, diag=None, ) -> dict: diag_stage = diag.stage if diag is not None else lambda *_a, **_k: None + def _session_has_server_visible_messages(session: dict) -> bool: + """Return True when a non-active sidebar row has a visibility signal. + + Keep this mirror of the non-active server filter narrow and local to + route behavior so model-layer behavior remains unchanged. + """ + if not isinstance(session, dict): + return False + if _numeric_count(session.get("message_count")) > 0: + return True + + attention = session.get("attention") + if isinstance(attention, dict) and attention.get("kind"): + if _numeric_count(attention.get("count")) > 0: + return True + + return bool( + session.get("is_streaming") + or session.get("active_stream_id") + or session.get("pending_user_message") + or session.get("has_pending_user_message") + ) + def _all_sessions_for_sidebar(): try: return all_sessions(diag=diag, include_lineage_metadata=False) @@ -2090,6 +2119,16 @@ def _build_session_list_cache_payload( diag_stage("cli_cap") archived_scoped = _cap_recent_cli_sessions(archived_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP) visible_scoped = _cap_recent_cli_sessions(visible_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP) + if visible_only: + archived_scoped = [ + s for s in archived_scoped if _session_has_server_visible_messages(s) + ] + visible_scoped = [ + s for s in visible_scoped if _session_has_server_visible_messages(s) + ] + if exclude_hidden: + archived_scoped = [s for s in archived_scoped if not s.get("default_hidden")] + visible_scoped = [s for s in visible_scoped if not s.get("default_hidden")] archived_webui_count = sum( 1 for s in archived_scoped if s.get("archived") and not _is_cli_session_for_settings(s) @@ -9794,6 +9833,7 @@ def handle_get(handler, parsed) -> bool: active_profile = get_active_profile_name() all_profiles = _all_profiles_enabled(parsed) include_archived = _query_flag(parsed, "include_archived") + exclude_hidden = _query_flag(parsed, "exclude_hidden") sidebar_source = parse_qs(parsed.query).get("sidebar_source", [""])[0].strip().lower() or None if sidebar_source not in ("webui", "cli"): sidebar_source = None @@ -9804,6 +9844,8 @@ def handle_get(handler, parsed) -> bool: show_previous_messaging_sessions=show_previous_messaging_sessions, show_cron_sessions=show_cron_sessions, include_archived=include_archived, + exclude_hidden=exclude_hidden, + visible_only=True, source_filter=agent_session_source_filter, sidebar_source=sidebar_source, ) @@ -9820,6 +9862,8 @@ def handle_get(handler, parsed) -> bool: show_previous_messaging_sessions=show_previous_messaging_sessions, show_cron_sessions=show_cron_sessions, include_archived=include_archived, + exclude_hidden=exclude_hidden, + visible_only=True, source_filter=agent_session_source_filter, sidebar_source=sidebar_source, diag=diag, diff --git a/static/sessions.js b/static/sessions.js index f01741bc..969271fc 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1640,6 +1640,7 @@ function _requestedSessionSidebarSource() { function _sessionListQueryString() { const qs = new URLSearchParams(); qs.set('sidebar_source', _requestedSessionSidebarSource()); + if(_activeProject===null || _activeProject===NO_PROJECT_FILTER) qs.set('exclude_hidden','1'); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); return `?${qs.toString()}`; diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index 46b36c20..e1df9628 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -253,6 +253,7 @@ def test_session_list_query_string_respects_sidebar_source_and_flags(): query_fn = _extract_function(src, "_sessionListQueryString") script = f""" global.window = {{ _showCliSessions: true }}; +global._activeProject = null; global._sessionSourceFilter = 'cli'; global._showAllProfiles = true; global._showArchived = false; @@ -262,12 +263,18 @@ const first = _sessionListQueryString(); window._showCliSessions = false; global._showArchived = true; const second = _sessionListQueryString(); -console.log(JSON.stringify({{ first, second }})); +global._activeProject = '__none__'; +global.NO_PROJECT_FILTER = '__none__'; +global._showAllProfiles = false; +global._showArchived = false; +const third = _sessionListQueryString(); +console.log(JSON.stringify({{ first, second, third }})); """ body = _run_node(script) - assert body["first"] == "?sidebar_source=cli&all_profiles=1" - assert body["second"] == "?sidebar_source=webui&all_profiles=1&include_archived=1" + assert body["first"] == "?sidebar_source=cli&exclude_hidden=1&all_profiles=1" + assert body["second"] == "?sidebar_source=webui&exclude_hidden=1&all_profiles=1&include_archived=1" + assert body["third"] == "?sidebar_source=webui&exclude_hidden=1" @pytest.mark.skipif(NODE is None, reason="node not on PATH") diff --git a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py new file mode 100644 index 00000000..4ab97577 --- /dev/null +++ b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py @@ -0,0 +1,298 @@ +"""Regression coverage for issue #4775: hide zero-message and default_hidden rows for default sidebar loads.""" + +import io +import json +from pathlib import Path +import shutil +import subprocess +from urllib.parse import urlparse + +import api.profiles as profiles +import api.routes as routes +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = ROOT / "static" / "sessions.js" +NODE = shutil.which("node") + + +class _FakeHandler: + def __init__(self): + self.status = None + self.headers = {} + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.headers[key] = value + + def end_headers(self): + pass + + def json_body(self): + return json.loads(self.wfile.getvalue().decode("utf-8")) + + +def _session_rows(): + return [ + { + "session_id": "visible-with-message", + "title": "Visible message", + "profile": "default", + "archived": False, + "message_count": 3, + "updated_at": 1000, + "last_message_at": 1000, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + "default_hidden": False, + }, + { + "session_id": "hidden-by-default", + "title": "Default hidden", + "profile": "default", + "archived": False, + "message_count": 1, + "updated_at": 900, + "last_message_at": 900, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + "default_hidden": True, + }, + { + "session_id": "zero-message", + "title": "Plain zero message", + "profile": "default", + "archived": False, + "message_count": 0, + "updated_at": 800, + "last_message_at": 800, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + "default_hidden": False, + }, + { + "session_id": "active-stream-row", + "title": "Streaming row", + "profile": "default", + "archived": False, + "message_count": 0, + "active_stream_id": "stream-active", + "updated_at": 700, + "last_message_at": 700, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + "default_hidden": False, + }, + { + "session_id": "attention-row", + "title": "Attention row", + "profile": "default", + "archived": False, + "message_count": 0, + "attention": {"kind": "clarify", "count": 1}, + "updated_at": 600, + "last_message_at": 600, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + "default_hidden": False, + }, + { + "session_id": "pending-user-row", + "title": "Pending user row", + "profile": "default", + "archived": False, + "message_count": 0, + "pending_user_message": "user typing", + "updated_at": 500, + "last_message_at": 500, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + "default_hidden": False, + }, + { + "session_id": "pending-flag-row", + "title": "Pending flag row", + "profile": "default", + "archived": False, + "message_count": 0, + "has_pending_user_message": True, + "updated_at": 550, + "last_message_at": 550, + "source": "webui", + "raw_source": "webui", + "session_source": "webui", + "source_tag": "webui", + "default_hidden": False, + }, + ] + + +def _handle_sessions(url): + handler = _FakeHandler() + routes.handle_get(handler, urlparse(url)) + return handler + + +def _extract_function(source_text, function_name): + marker = f"function {function_name}(" + start = source_text.index(marker) + brace_start = source_text.index("{", start) + depth = 0 + for index in range(brace_start, len(source_text)): + char = source_text[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source_text[start : index + 1] + raise AssertionError(f"Could not extract {function_name}") + + +def _run_node(script): + proc = subprocess.run([NODE, "-e", script], capture_output=True, text=True, check=True) + return json.loads(proc.stdout) + + +@pytest.fixture(autouse=True) +def _clear_cache(): + routes._session_list_cache_clear() + yield + routes._session_list_cache_clear() + + +def _install_common_monkeypatches(monkeypatch, rows): + session_ids = {str(row["session_id"]) for row in rows if row.get("session_id")} + monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows)) + monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _rows: False) + monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", lambda _rows: None) + monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: []) + monkeypatch.setattr(routes, "agent_session_rows_existing", lambda ids, profile=None: set(ids) & session_ids) + monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": True}) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: "default") + return rows + + +def test_default_sidebar_excludes_hidden_and_plain_zero_message_rows(monkeypatch): + rows = _session_rows() + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&exclude_hidden=1") + body = handler.json_body() + + assert handler.status == 200 + assert [r["session_id"] for r in body["sessions"]] == [ + "visible-with-message", + "active-stream-row", + "attention-row", + "pending-flag-row", + "pending-user-row", + ] + + +def test_zero_message_rows_with_visibility_signals_survive(monkeypatch): + rows = _session_rows() + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&exclude_hidden=1") + body = handler.json_body() + + assert handler.status == 200 + assert [r["session_id"] for r in body["sessions"]] == [ + "visible-with-message", + "active-stream-row", + "attention-row", + "pending-flag-row", + "pending-user-row", + ] + + +def test_omit_exclude_hidden_still_returns_default_hidden_rows(monkeypatch): + rows = _session_rows() + _install_common_monkeypatches(monkeypatch, rows) + + handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui") + body = handler.json_body() + + assert handler.status == 200 + session_ids = {row["session_id"] for row in body["sessions"]} + assert "hidden-by-default" in session_ids + + +def test_default_and_unassigned_queries_send_exclude_hidden(monkeypatch): + src = SESSIONS_JS.read_text(encoding="utf-8") + requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + query_fn = _extract_function(src, "_sessionListQueryString") + script = f""" +global.window = {{ _showCliSessions: false }}; +global._showCliSessions = false; +global._showAllProfiles = false; +global._showArchived = false; +global._activeProject = null; +global.NO_PROJECT_FILTER = '__none__'; +{requested_source_fn} +{query_fn} +const default_query = _sessionListQueryString(); +global._activeProject = '__none__'; +const unassigned_query = _sessionListQueryString(); +global._activeProject = 'demo-project'; +const named_project_query = _sessionListQueryString(); +console.log(JSON.stringify({{ default_query, unassigned_query, named_project_query }})); +""" + body = _run_node(script) + + assert body["default_query"] == "?sidebar_source=webui&exclude_hidden=1" + assert body["unassigned_query"] == "?sidebar_source=webui&exclude_hidden=1" + assert body["named_project_query"] == "?sidebar_source=webui" + + +def test_cache_key_varies_for_exclude_hidden_and_visible_only(): + key_without_filters = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + sidebar_source="webui", + ) + key_exclude_hidden = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + exclude_hidden=True, + sidebar_source="webui", + ) + key_visible_only = routes._session_list_cache_key( + active_profile="default", + all_profiles=False, + show_cli_sessions=True, + show_previous_messaging_sessions=False, + show_cron_sessions=False, + include_archived=False, + visible_only=True, + sidebar_source="webui", + ) + + assert key_without_filters != key_exclude_hidden + assert key_without_filters != key_visible_only + assert key_exclude_hidden != key_visible_only From df6dba7a94e8145674dafbb9f52b19aeafaa2d42 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 10:40:40 -0400 Subject: [PATCH 29/45] fix(sidebar): refresh project-chip payload after filter switch (#4775) --- static/sessions.js | 14 +++++-- ...75_sidebar_hidden_zero_message_pushdown.py | 42 ++++++++++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 969271fc..fe0fa44d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1652,6 +1652,14 @@ function _sessionSourceTabCount(filter, renderedWebuiSessionCount, renderedCliSe return filter === 'cli' ? renderedCliSessionCount : renderedWebuiSessionCount; } +function _setActiveProjectFilter(projectId) { + const next = projectId === NO_PROJECT_FILTER ? NO_PROJECT_FILTER : (projectId || null); + if (_activeProject === next) return; + _activeProject = next; + renderSessionListFromCache(); + void renderSessionList({deferWhileInteracting:false}); +} + function _setSessionSourceFilter(filter) { const next = filter === 'cli' ? 'cli' : 'webui'; if (_sessionSourceFilter === next) return; @@ -5740,7 +5748,7 @@ function renderSessionListFromCache(){ const allChip=document.createElement('span'); allChip.className='project-chip'+(!_activeProject?' active':''); allChip.textContent='All'; - allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();}; + allChip.onclick=()=>{_setActiveProjectFilter(null);}; bar.appendChild(allChip); // "Unassigned" chip — only when there are sessions with no project to // filter to. Hidden in the common case where every session is already @@ -5750,7 +5758,7 @@ function renderSessionListFromCache(){ noneChip.className='project-chip no-project'+(_activeProject===NO_PROJECT_FILTER?' active':''); noneChip.textContent='Unassigned'; noneChip.title='Show conversations not yet assigned to a project'; - noneChip.onclick=()=>{_activeProject=NO_PROJECT_FILTER;renderSessionListFromCache();}; + noneChip.onclick=()=>{_setActiveProjectFilter(NO_PROJECT_FILTER);}; bar.appendChild(noneChip); } // Project chips @@ -5769,7 +5777,7 @@ function renderSessionListFromCache(){ let _pClickTimer=null; chip.onclick=(e)=>{ clearTimeout(_pClickTimer); - _pClickTimer=setTimeout(()=>{_pClickTimer=null;_activeProject=p.project_id;renderSessionListFromCache();},220); + _pClickTimer=setTimeout(()=>{_pClickTimer=null;_setActiveProjectFilter(p.project_id);},220); }; chip.ondblclick=(e)=>{e.stopPropagation();clearTimeout(_pClickTimer);_pClickTimer=null;_startProjectRename(p,chip);}; chip.oncontextmenu=(e)=>{e.preventDefault();_showProjectContextMenu(e,p,chip);}; diff --git a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py index 4ab97577..d78d27fb 100644 --- a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py +++ b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py @@ -238,6 +238,7 @@ def test_omit_exclude_hidden_still_returns_default_hidden_rows(monkeypatch): def test_default_and_unassigned_queries_send_exclude_hidden(monkeypatch): src = SESSIONS_JS.read_text(encoding="utf-8") requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + project_filter_fn = _extract_function(src, "_setActiveProjectFilter") query_fn = _extract_function(src, "_sessionListQueryString") script = f""" global.window = {{ _showCliSessions: false }}; @@ -246,12 +247,15 @@ global._showAllProfiles = false; global._showArchived = false; global._activeProject = null; global.NO_PROJECT_FILTER = '__none__'; +global.renderSessionListFromCache = () => {{}}; +global.renderSessionList = () => Promise.resolve(); {requested_source_fn} +{project_filter_fn} {query_fn} const default_query = _sessionListQueryString(); -global._activeProject = '__none__'; +_setActiveProjectFilter('__none__'); const unassigned_query = _sessionListQueryString(); -global._activeProject = 'demo-project'; +_setActiveProjectFilter('demo-project'); const named_project_query = _sessionListQueryString(); console.log(JSON.stringify({{ default_query, unassigned_query, named_project_query }})); """ @@ -262,6 +266,40 @@ console.log(JSON.stringify({{ default_query, unassigned_query, named_project_que assert body["named_project_query"] == "?sidebar_source=webui" +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_project_filter_click_path_triggers_fresh_session_load(): + src = SESSIONS_JS.read_text(encoding="utf-8") + project_filter_fn = _extract_function(src, "_setActiveProjectFilter") + script = f""" +const calls = []; +global.NO_PROJECT_FILTER = '__none__'; +global._activeProject = null; +global.renderSessionListFromCache = () => {{ + calls.push('cache'); +}}; +global.renderSessionList = (opts) => {{ + calls.push(opts); + return Promise.resolve(); +}}; +{project_filter_fn} +_setActiveProjectFilter('demo-project'); +_setActiveProjectFilter('__none__'); +console.log(JSON.stringify({{ + activeProject: global._activeProject, + calls, +}})); +""" + body = _run_node(script) + + assert body["activeProject"] == "__none__" + assert body["calls"] == [ + "cache", + {"deferWhileInteracting": False}, + "cache", + {"deferWhileInteracting": False}, + ] + + def test_cache_key_varies_for_exclude_hidden_and_visible_only(): key_without_filters = routes._session_list_cache_key( active_profile="default", From e9baeff2b20125bee6449a51570473d45125e9c2 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 10:51:33 -0400 Subject: [PATCH 30/45] fix(sidebar): preserve attention rows in filtered session payloads (#4775) --- api/routes.py | 2 ++ ...est_issue4775_sidebar_hidden_zero_message_pushdown.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/api/routes.py b/api/routes.py index bb63f4aa..1915b7ec 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1950,6 +1950,8 @@ def _build_session_list_cache_payload( return True attention = session.get("attention") + if not (isinstance(attention, dict) and attention.get("kind")): + attention = _session_attention_summary(str(session.get("session_id") or "")) if isinstance(attention, dict) and attention.get("kind"): if _numeric_count(attention.get("count")) > 0: return True diff --git a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py index d78d27fb..b1cc76ff 100644 --- a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py +++ b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py @@ -101,7 +101,6 @@ def _session_rows(): "profile": "default", "archived": False, "message_count": 0, - "attention": {"kind": "clarify", "count": 1}, "updated_at": 600, "last_message_at": 600, "source": "webui", @@ -182,6 +181,13 @@ def _install_common_monkeypatches(monkeypatch, rows): monkeypatch.setattr(routes, "all_sessions", lambda diag=None: list(rows)) monkeypatch.setattr(routes, "_reconcile_stale_stream_state_for_session_rows", lambda _rows: False) monkeypatch.setattr(routes, "_enrich_sidebar_lineage_metadata", lambda _rows: None) + monkeypatch.setattr( + routes, + "_session_attention_summary", + lambda session_id: {"kind": "clarify", "count": 1} + if str(session_id) == "attention-row" + else None, + ) monkeypatch.setattr(routes, "get_cli_sessions", lambda source_filter=None, all_profiles=False: []) monkeypatch.setattr(routes, "agent_session_rows_existing", lambda ids, profile=None: set(ids) & session_ids) monkeypatch.setattr(routes, "load_settings", lambda: {"show_cli_sessions": True}) @@ -235,6 +241,7 @@ def test_omit_exclude_hidden_still_returns_default_hidden_rows(monkeypatch): assert "hidden-by-default" in session_ids +@pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_default_and_unassigned_queries_send_exclude_hidden(monkeypatch): src = SESSIONS_JS.read_text(encoding="utf-8") requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") From 5ccabedcd44de2081792204abb3aac6dd68f37d4 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 10:56:42 -0400 Subject: [PATCH 31/45] test(sidebar): keep unassigned filter assertions aligned with refresh helper (#4775) --- tests/test_sidebar_unassigned_filter.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_sidebar_unassigned_filter.py b/tests/test_sidebar_unassigned_filter.py index 0db21751..2d19a7ee 100644 --- a/tests/test_sidebar_unassigned_filter.py +++ b/tests/test_sidebar_unassigned_filter.py @@ -94,8 +94,9 @@ def test_unassigned_chip_label_and_handler(): assert "noneChip.textContent='Unassigned';" in js, ( "The Unassigned chip must display the label 'Unassigned'" ) - assert "_activeProject=NO_PROJECT_FILTER" in js, ( - "Clicking the Unassigned chip must set _activeProject to the sentinel" + assert "_setActiveProjectFilter(NO_PROJECT_FILTER)" in js, ( + "Clicking the Unassigned chip must route through the helper that sets " + "the sentinel and refreshes the payload" ) # Active-state contract — the chip must reflect when it's the active filter. assert "_activeProject===NO_PROJECT_FILTER?' active':''" in js, ( @@ -142,8 +143,7 @@ def test_all_chip_clear_clears_unassigned_filter_too(): js = _js() # Find the "All" chip handler. It must clear _activeProject to null and # NOT preserve any unassigned-flag state. - assert "allChip.onclick=()=>{_activeProject=null;renderSessionListFromCache();};" in js, ( - "The All chip handler must reset _activeProject to null. If a parallel " - "_showNoneProject boolean is reintroduced, this test will catch it because " - "the handler will need additional state to reset." + assert "allChip.onclick=()=>{_setActiveProjectFilter(null);};" in js, ( + "The All chip handler must route through the helper that clears " + "_activeProject and refreshes the filtered payload." ) From 1269289d012916885922da9ba9b87ab06d90213e Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 23 Jun 2026 23:00:32 +0800 Subject: [PATCH 32/45] fix: guard live worklog scroll rebuilds --- CHANGELOG.md | 4 ++ static/ui.js | 42 +++++++++++++- .../test_issue3479_ios_stream_scroll_jump.py | 21 ++++++- .../test_issue4295_midstream_scroll_anchor.py | 58 +++++++++++++++++++ 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b5a2af..dd221ab1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Compact Worklog no longer snaps back to the user prompt during live-stream rebuilds.** Follow-up to the mid-stream scroll restore: the live Anchor scene now holds the transcript's scroll height stable while it removes and rebuilds Worklog rows, so the browser cannot temporarily clamp an unpinned reader's `scrollTop` to the top before the replacement rows land. Pinned-at-bottom streaming still follows the latest output. + ## [v0.51.603] — 2026-06-23 — Release VJ (cache the app-shell template) ### Changed diff --git a/static/ui.js b/static/ui.js index 1c9f759c..96d5f890 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9571,6 +9571,39 @@ function _projectLiveAnchorActivitySceneForStream(streamId, mode){ return null; } } +function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){ + const messagesEl=$('messages'); + if(!messagesEl||!scrollSnapshot) return {readerAwayFromBottom:false,release:null}; + const beforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight); + const readerAwayFromBottom=beforeBottomDistance>250&&(_messageUserUnpinned||messagesEl.scrollTop>0); + if(!readerAwayFromBottom) return {readerAwayFromBottom:false,release:null}; + scrollSnapshot.pinned=false; + scrollSnapshot.userUnpinned=true; + scrollSnapshot.bottom=beforeBottomDistance; + _messageUserUnpinned=true; + _scrollPinned=false; + _nearBottomCount=0; + const msgInner=$('msgInner'); + if(!msgInner||!msgInner.style) return {readerAwayFromBottom:true,release:null}; + const guardPreviousKey='liveAnchorScrollGuardPreviousMinHeight'; + let previousMinHeight=msgInner.style.minHeight||''; + if(msgInner.dataset&&Object.prototype.hasOwnProperty.call(msgInner.dataset,guardPreviousKey)){ + previousMinHeight=msgInner.dataset[guardPreviousKey]||''; + }else if(msgInner.dataset){ + msgInner.dataset[guardPreviousKey]=previousMinHeight; + } + const guardHeight=Math.max(messagesEl.scrollHeight,Number(scrollSnapshot.scrollHeight)||0); + if(guardHeight>0) msgInner.style.minHeight=`${guardHeight}px`; + return { + readerAwayFromBottom:true, + release:()=>{ + msgInner.style.minHeight=previousMinHeight; + if(msgInner.dataset&&msgInner.dataset[guardPreviousKey]===previousMinHeight){ + delete msgInner.dataset[guardPreviousKey]; + } + }, + }; +} function renderLiveAnchorActivityScene(streamId, scene, opts){ opts=opts||{}; if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false; @@ -9595,6 +9628,7 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ ? _captureWorklogDetailDisclosureState(blocks) : null; const scrollSnapshot=_captureMessageScrollSnapshot(); + const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(scrollSnapshot); blocks.querySelectorAll('[data-anchor-scene-owner="1"],[data-anchor-scene-row="1"]').forEach(el=>el.remove()); blocks.querySelectorAll('.live-worklog[data-live-worklog-shell="1"],.tool-worklog-group[data-live-tool-call-group="1"],.tool-call-group[data-live-tool-call-group="1"],.tool-card-row[data-live-tid]:not(.transparent-event-row),.agent-activity-thinking[data-live-thinking="1"],.interim-collapse-toggle').forEach(el=>el.remove()); blocks.querySelectorAll('[data-live-assistant="1"]').forEach(el=>{ @@ -9619,7 +9653,13 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ _dedupeLiveProcessedWorklogAnchors(turn); if(typeof _moveLiveRunStatusToTurnEnd==='function') _moveLiveRunStatusToTurnEnd(); _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); - if(typeof scrollIfPinned==='function') scrollIfPinned(); + if(scrollRebuildGuard&&scrollRebuildGuard.release){ + requestAnimationFrame(()=>{ + scrollRebuildGuard.release(); + _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + }); + } + if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); return true; } function _renderLiveAnchorActivitySceneForStream(streamId, sessionId, opts){ diff --git a/tests/test_issue3479_ios_stream_scroll_jump.py b/tests/test_issue3479_ios_stream_scroll_jump.py index 98934e2b..9246c7fd 100644 --- a/tests/test_issue3479_ios_stream_scroll_jump.py +++ b/tests/test_issue3479_ios_stream_scroll_jump.py @@ -68,14 +68,31 @@ def test_live_anchor_worklog_rebuild_restores_snapshot_before_follow_settle(): body = _function_body(UI_JS, "renderLiveAnchorActivityScene") capture_idx = body.index("const scrollSnapshot=_captureMessageScrollSnapshot();") + guard_idx = body.index("const scrollRebuildGuard=_prepareLiveAnchorScrollRebuildGuard(scrollSnapshot);") remove_idx = body.index("blocks.querySelectorAll('[data-anchor-scene-owner=\"1\"],[data-anchor-scene-row=\"1\"]')") restore_detail_idx = body.index("_restoreWorklogDetailDisclosureState(blocks, liveDisclosureState);") dedupe_idx = body.index("_dedupeLiveProcessedWorklogAnchors(turn);") move_status_idx = body.index("_moveLiveRunStatusToTurnEnd();") restore_idx = body.index("_restoreMessageScrollSnapshotSameFrame(scrollSnapshot);") - settle_idx = body.index("if(typeof scrollIfPinned==='function') scrollIfPinned();") + release_idx = body.index("if(scrollRebuildGuard&&scrollRebuildGuard.release)") + settle_idx = body.index("if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned();") - assert capture_idx < remove_idx < restore_detail_idx < dedupe_idx < move_status_idx < restore_idx < settle_idx + assert capture_idx < guard_idx < remove_idx < restore_detail_idx < dedupe_idx < move_status_idx < restore_idx < release_idx < settle_idx + + +def test_live_anchor_worklog_rebuild_guards_height_for_unpinned_reader(): + guard = _function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard") + compact = _compact(guard) + + assert "constbeforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight);" in compact + assert "beforeBottomDistance>250&&(_messageUserUnpinned||messagesEl.scrollTop>0)" in compact + assert "scrollSnapshot.pinned=false;" in compact + assert "scrollSnapshot.userUnpinned=true;" in compact + assert "scrollSnapshot.bottom=beforeBottomDistance;" in compact + assert "_messageUserUnpinned=true;" in compact + assert "_scrollPinned=false;" in compact + assert "_nearBottomCount=0;" in compact + assert "msgInner.style.minHeight=`${guardHeight}px`;" in compact def test_same_frame_snapshot_preserves_bottom_distance_and_unpinned_state(): diff --git a/tests/test_issue4295_midstream_scroll_anchor.py b/tests/test_issue4295_midstream_scroll_anchor.py index 759a54c6..fe817d4c 100644 --- a/tests/test_issue4295_midstream_scroll_anchor.py +++ b/tests/test_issue4295_midstream_scroll_anchor.py @@ -107,6 +107,64 @@ assert.strictEqual(_programmaticScroll, false); subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) +def test_live_anchor_rebuild_guard_holds_height_and_marks_reader_unpinned(): + """Live anchor rebuilds must not let the scroll container collapse mid-read.""" + + script = f""" +const assert = require('assert'); +let _messageUserUnpinned = false; +let _scrollPinned = true; +let _nearBottomCount = 2; +const messages = {{ + scrollTop: 2000, + scrollHeight: 5000, + clientHeight: 600, +}}; +const inner = {{ style: {{ minHeight: '' }}, dataset: {{}} }}; +function $(id) {{ + if (id === 'messages') return messages; + if (id === 'msgInner') return inner; + return null; +}} +{_function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")} +const snapshot = {{ + top: 2000, + bottom: 0, + scrollHeight: 5000, + pinned: true, + userUnpinned: false, +}}; +const guard = _prepareLiveAnchorScrollRebuildGuard(snapshot); +assert.strictEqual(guard.readerAwayFromBottom, true); +assert.strictEqual(snapshot.pinned, false); +assert.strictEqual(snapshot.userUnpinned, true); +assert.strictEqual(snapshot.bottom, 2400); +assert.strictEqual(_messageUserUnpinned, true); +assert.strictEqual(_scrollPinned, false); +assert.strictEqual(_nearBottomCount, 0); +assert.strictEqual(inner.style.minHeight, '5000px'); +assert.strictEqual(inner.dataset.liveAnchorScrollGuardPreviousMinHeight, ''); +messages.scrollHeight = 5200; +const nestedSnapshot = {{ + top: 2000, + bottom: 0, + scrollHeight: 5200, + pinned: true, + userUnpinned: false, +}}; +const nestedGuard = _prepareLiveAnchorScrollRebuildGuard(nestedSnapshot); +assert.strictEqual(nestedGuard.readerAwayFromBottom, true); +assert.strictEqual(inner.style.minHeight, '5200px'); +assert.strictEqual(inner.dataset.liveAnchorScrollGuardPreviousMinHeight, ''); +guard.release(); +assert.strictEqual(inner.style.minHeight, ''); +nestedGuard.release(); +assert.strictEqual(inner.style.minHeight, ''); +assert.strictEqual(Object.prototype.hasOwnProperty.call(inner.dataset, 'liveAnchorScrollGuardPreviousMinHeight'), false); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_restore_message_scroll_snapshot_keeps_intermediate_distance_state(): script = f""" const assert = require('assert'); From 9296604667c5f8ed7c1b627ca1fdeb7ebaefb29a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 11:00:40 -0400 Subject: [PATCH 33/45] fix(sidebar): keep delete fallback in the visible session scope (#4775) --- static/sessions.js | 4 ++-- tests/test_session_action_menu_regression.py | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index fe0fa44d..8b2a2c0a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3082,7 +3082,7 @@ function _renderBatchActionBar(){ if(S.session&&ids.includes(S.session.session_id)){ S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session'); if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(null); - const remaining=await api('/api/sessions'); + const remaining=await api('/api/sessions'+_sessionListQueryString()); if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);} else{$('msgInner').innerHTML='';$('emptyState').style.display='';} } @@ -7107,7 +7107,7 @@ async function deleteSession(sid, beforeDelete=null){ if(typeof _hydrateTodosFromSession==='function') _hydrateTodosFromSession(null); localStorage.removeItem('hermes-webui-session'); // load the most recent remaining session, or show blank if none left - const remaining=await api('/api/sessions'); + const remaining=await api('/api/sessions'+_sessionListQueryString()); if(remaining.sessions&&remaining.sessions.length){ await loadSession(remaining.sessions[0].session_id); }else{ diff --git a/tests/test_session_action_menu_regression.py b/tests/test_session_action_menu_regression.py index 05de2c55..e9fd77c1 100644 --- a/tests/test_session_action_menu_regression.py +++ b/tests/test_session_action_menu_regression.py @@ -52,9 +52,13 @@ def test_delete_action_repaints_sidebar_before_loading_remaining_sessions(): api_call = "const deleteRequest=api('/api/session/delete'" optimistic = "_optimisticallyRemoveSessionFromList(sid);" - remaining_fetch = "const remaining=await api('/api/sessions');" + remaining_fetch = "const remaining=await api('/api/sessions'+_sessionListQueryString());" full_refresh = "await renderSessionList();" assert optimistic in body assert body.index(api_call) < body.index(optimistic) < body.index(full_refresh) assert body.index(optimistic) < body.index(remaining_fetch) + +def test_batch_delete_remaining_session_fetch_uses_sidebar_query_string(): + """Batch delete must reload the default sidebar through the same query builder.""" + assert SESSIONS_JS.count("const remaining=await api('/api/sessions'+_sessionListQueryString());") >= 2 From 4c8e88ab2d613ad513e5c9b3cc88b77976a67ca2 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 23 Jun 2026 22:58:25 +0800 Subject: [PATCH 34/45] fix: dedupe live compact worklog process prose --- CHANGELOG.md | 4 ++ static/messages.js | 38 +++++++++-- static/ui.js | 25 +++++++ tests/test_issue2403_interim_collapse.py | 17 ++++- tests/test_live_anchor_progress_echo.py | 15 +++- ...test_live_to_final_anchor_visible_order.py | 68 +++++++++++++++++++ 6 files changed, 158 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b5a2af..dc2d0e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- **Compact Worklog no longer shows duplicate live process prose while a response is still streaming.** When the same progress sentence arrives through both interim assistant text and Anchor activity rows, the live scene now keeps one visible Process row, removes matching live reasoning echoes from the Worklog DOM, and hides legacy source assistant segments so they cannot appear as duplicate transcript prose. + ## [v0.51.603] — 2026-06-23 — Release VJ (cache the app-shell template) ### Changed diff --git a/static/messages.js b/static/messages.js index 3888c762..9f5833f5 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2831,6 +2831,34 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } return false; } + function _removeLiveReasoningEchoRows(visible){ + const turn=$('liveAssistantTurn'); + const blocks=turn&&typeof _assistantTurnBlocks==='function'?_assistantTurnBlocks(turn):null; + if(!blocks||!visible) return false; + let removed=false; + const selector=[ + '.agent-activity-thinking[data-anchor-scene-row="1"]', + '.agent-activity-thinking[data-live-thinking="1"]', + '.wl-reason[data-worklog-anchor-reason="1"]', + '.wl-reason[data-worklog-reason-source="reasoning"]' + ].join(','); + blocks.querySelectorAll(selector).forEach(row=>{ + const textNode=row.querySelector&&( + row.querySelector('.thinking-card-body pre') || + row.querySelector('.thinking-card-body') + ); + const text=String((textNode&&textNode.textContent)||row.textContent||''); + if(!_stripCompactEchoSuffix(text, visible).removed) return; + row.remove(); + removed=true; + }); + if(removed&&typeof _syncToolCallGroupSummary==='function'){ + blocks.querySelectorAll('.tool-worklog-group,.tool-call-group').forEach(group=>{ + _syncToolCallGroupSummary(group); + }); + } + return removed; + } function _stripLiveReasoningEcho(visible){ let removed=false; const durable=_stripCompactEchoSuffix(reasoningText, visible); @@ -2844,11 +2872,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ removed=true; } const anchorRemoved=_stripAnchorReasoningEcho(visible); + const domRemoved=_removeLiveReasoningEchoRows(visible); if(removed) syncInflightAssistantMessage(); - if((removed||anchorRemoved)&&!String(liveReasoningText||'').trim()&&typeof removeThinking==='function'){ + if((removed||anchorRemoved||domRemoved)&&!String(liveReasoningText||'').trim()&&typeof removeThinking==='function'){ removeThinking(); } - return removed||anchorRemoved; + return removed||anchorRemoved||domRemoved; } function _flushReasoningToAnchor(){ if(_anchorReasoningFlushed||!reasoningText) return; @@ -3404,6 +3433,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } function _flushPendingSegmentRender(options={}){ const force=!!(options&&options.force); + const skipAnchorProcessProse=!!(options&&options.skipAnchorProcessProse); if(!assistantBody||(!force&&!_renderPending)) return; if(_renderPending) _cancelAnimationFramePendingStreamRender(); const displayText=segmentStart===0 @@ -3428,7 +3458,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } else { assistantBody.innerHTML=esc(displayText); } - _upsertAnchorProcessProse(displayText,{sealed:force}); + if(!skipAnchorProcessProse) _upsertAnchorProcessProse(displayText,{sealed:force}); if(typeof _syncLiveWorklogReasonsForAnchor==='function') _syncLiveWorklogReasonsForAnchor(assistantRow, displayText); } function _resetAssistantSegment(){ @@ -3871,7 +3901,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _completeAutomaticCompressionOnLiveProgress(activeSid); ensureAssistantRow(true); if(assistantRow) assistantRow.setAttribute('data-interim','1'); - _flushPendingSegmentRender({force:true}); + _flushPendingSegmentRender({force:true,skipAnchorProcessProse:true}); if(typeof finalizeThinkingCard==='function') finalizeThinkingCard(); if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup(); _applyToAnchor('interim_assistant',d,e); diff --git a/static/ui.js b/static/ui.js index 1c9f759c..b35874d5 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9079,6 +9079,7 @@ function _syncWorklogReasonFromAnchor(group, anchor, displayTextOverride){ if(anchor){ anchor.classList.add('assistant-segment-worklog-source'); anchor.setAttribute('aria-hidden','true'); + anchor.hidden=true; } } function ensureLiveWorklogContainer(blocks, opts){ @@ -9142,6 +9143,7 @@ function _appendWorklogReason(list, anchor){ if(anchor){ anchor.classList.add('assistant-segment-worklog-source'); anchor.setAttribute('aria-hidden','true'); + anchor.hidden=true; } return reason; } @@ -9263,8 +9265,11 @@ function _appendWorklogStep(group, anchor, cards, thinkingText, opts){ function _anchorSceneRowsForRendering(scene, opts){ const rows=Array.isArray(scene&&scene.activity_rows)?scene.activity_rows:[]; const settled=!!(opts&&opts.settled); + const live=!settled; const out=[]; const byKey=new Map(); + const liveProseTextKeys=new Map(); + const proseTextKey=(value)=>String(value||'').replace(/\s+/g,' ').trim(); const keyFor=(row)=>{ if(!row) return ''; if(row.role==='tool') return `tool:${_anchorSceneToolRowLogicalKey(row)||row.row_id||row.event_id||row.local_id||out.length}`; @@ -9286,8 +9291,23 @@ function _anchorSceneRowsForRendering(scene, opts){ const key=keyFor(row); if(byKey.has(key)){ const index=byKey.get(key); + if(live&&row.role==='prose'){ + const textKey=proseTextKey(text); + const duplicateIndex=textKey?liveProseTextKeys.get(textKey):undefined; + if(duplicateIndex!==undefined&&duplicateIndex!==index) continue; + const previousTextKey=proseTextKey(out[index]&&out[index].text); + if(previousTextKey&&previousTextKey!==textKey&&liveProseTextKeys.get(previousTextKey)===index){ + liveProseTextKeys.delete(previousTextKey); + } + if(textKey) liveProseTextKeys.set(textKey,index); + } out[index]=row.role==='tool'?_anchorSceneMergeToolRows(out[index],row):row; }else{ + if(live&&row.role==='prose'){ + const textKey=proseTextKey(text); + if(textKey&&liveProseTextKeys.has(textKey)) continue; + if(textKey) liveProseTextKeys.set(textKey,out.length); + } byKey.set(key,out.length); out.push(row); } @@ -9600,6 +9620,7 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ blocks.querySelectorAll('[data-live-assistant="1"]').forEach(el=>{ el.classList.add('assistant-segment-worklog-source'); el.setAttribute('aria-hidden','true'); + el.hidden=true; }); const group=_anchorSceneWorklogGroup(blocks,{ live:true, @@ -9664,6 +9685,7 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx if(Number.isFinite(idx)&&idxel.remove()); @@ -11411,6 +11434,7 @@ function renderMessages(options){ if(messageBelongsInWorklog){ seg.classList.add('assistant-segment-worklog-source'); seg.setAttribute('aria-hidden','true'); + seg.hidden=true; } if(m._live){ currentAssistantTurn.id='liveAssistantTurn'; @@ -12148,6 +12172,7 @@ function renderMessages(options){ if(!(seg.textContent||'').trim()) continue; seg.classList.remove('assistant-segment-worklog-source'); seg.removeAttribute('aria-hidden'); + seg.hidden=false; } } } diff --git a/tests/test_issue2403_interim_collapse.py b/tests/test_issue2403_interim_collapse.py index c271de6c..9d5ce5eb 100644 --- a/tests/test_issue2403_interim_collapse.py +++ b/tests/test_issue2403_interim_collapse.py @@ -30,6 +30,18 @@ def _extract_interim_handler(src): pos = idx +def _visible_interim_flush_pos(fn): + attr_pos = fn.index("setAttribute('data-interim','1')") + candidates = [ + "_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true})", + "_flushPendingSegmentRender({force:true})", + ] + positions = [fn.find(candidate, attr_pos) for candidate in candidates] + positions = [pos for pos in positions if pos != -1] + assert positions, "visible interim flush call not found after data-interim marker" + return min(positions) + + class TestInterimCollapseHandlerStructure: """The interim_assistant handler must contain the collapse threshold and logic.""" @@ -93,7 +105,7 @@ class TestInterimCollapseHandlerStructure: src = read("static/messages.js") fn = _extract_interim_handler(src) attr_pos = fn.index("setAttribute('data-interim','1')") - flush_pos = fn.rindex("_flushPendingSegmentRender({force:true})") + flush_pos = _visible_interim_flush_pos(fn) assert attr_pos < flush_pos, ( "data-interim attribute must be set before _flushPendingSegmentRender " "so the segment is marked before it is sealed" @@ -102,7 +114,7 @@ class TestInterimCollapseHandlerStructure: def test_collapse_after_flush_before_reset(self): src = read("static/messages.js") fn = _extract_interim_handler(src) - flush_pos = fn.index("_flushPendingSegmentRender({force:true})") + flush_pos = _visible_interim_flush_pos(fn) collapse_pos = fn.index("INTERIM_COLLAPSE_THRESHOLD") reset_pos = fn.index("_resetAssistantSegment()", collapse_pos) assert flush_pos < collapse_pos < reset_pos, ( @@ -201,4 +213,3 @@ class TestInterimCollapseSurvivesLiveTurnRestore: "toggle must carry data-threshold so the delegated handler can " "recompute the collapse set without closure state after a restore." ) - diff --git a/tests/test_live_anchor_progress_echo.py b/tests/test_live_anchor_progress_echo.py index f20438d4..2b988c65 100644 --- a/tests/test_live_anchor_progress_echo.py +++ b/tests/test_live_anchor_progress_echo.py @@ -27,20 +27,31 @@ def test_interim_reasoning_echo_cleans_live_and_anchor_thinking(): assert "const reasoningEcho=!!(d&&d.reasoning_echo);" in body assert "if(reasoningEcho) _stripLiveReasoningEcho(visible);" in body assert "function _stripAnchorReasoningEcho(visible)" in MESSAGES + assert "function _removeLiveReasoningEchoRows(visible)" in MESSAGES assert "events.splice(i,1);" in MESSAGES + assert '.agent-activity-thinking[data-anchor-scene-row="1"]' in MESSAGES + assert "_removeLiveReasoningEchoRows(visible)" in MESSAGES assert "reasoningText=durable.text;" in MESSAGES assert "liveReasoningText=live.text;" in MESSAGES -def test_interim_anchor_render_runs_after_legacy_segment_flush(): +def test_interim_anchor_render_runs_after_legacy_segment_flush_without_duplicate_process_row(): body = _interim_listener_body() - flush_idx = body.index("_flushPendingSegmentRender({force:true});") + flush_idx = body.index("_flushPendingSegmentRender({force:true,skipAnchorProcessProse:true});") anchor_idx = body.index("_applyToAnchor('interim_assistant',d,e);") + flush_fn_start = MESSAGES.index("function _flushPendingSegmentRender") + flush_fn = MESSAGES[flush_fn_start : MESSAGES.index("function _resetAssistantSegment", flush_fn_start)] + + assert "const skipAnchorProcessProse=!!(options&&options.skipAnchorProcessProse);" in flush_fn + assert "if(!skipAnchorProcessProse) _upsertAnchorProcessProse(displayText,{sealed:force});" in flush_fn assert flush_idx < anchor_idx, ( "Anchor live scene must render after the legacy interim segment is flushed, " "so renderLiveAnchorActivityScene can hide that source segment immediately." ) + assert "_flushPendingSegmentRender({force:true});" in body, ( + "already_streamed interim updates must still flush the token-owned prose row." + ) def test_live_anchor_scene_hides_legacy_live_assistant_sources(): diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index f3702d1c..fa56fa90 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -338,12 +338,78 @@ def test_scene_renderer_coalesces_row_updates_and_renders_in_scene_order(): render = _function_body(UI_JS, "_renderAnchorSceneRowsIntoWorklog") live = _function_body(UI_JS, "renderLiveAnchorActivityScene") + assert "const live=!settled" in rows + assert "const liveProseTextKeys=new Map()" in rows + assert "if(textKey&&liveProseTextKeys.has(textKey)) continue;" in rows assert "byKey.set(key,out.length)" in rows assert "out[index]=row.role==='tool'?_anchorSceneMergeToolRows(out[index],row):row" in rows assert "for(const row of rows)" in render assert "_anchorSceneNodeForRow(row,opts)" in render assert "blocks.querySelectorAll('[data-live-assistant=\"1\"]')" in live assert "assistant-segment-worklog-source" in live + assert "el.hidden=true" in live + + +@pytest.mark.skipif(NODE is None, reason="node is required for anchor row normalization tests") +def test_live_anchor_scene_dedupes_exact_duplicate_process_prose_only_live(): + script = f""" +const fs = require('fs'); +const src = fs.readFileSync({json.dumps(str(ROOT / "static" / "ui.js"))}, 'utf8'); +function extractFunc(name){{ + const start = src.indexOf('function ' + name); + if(start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for(let i=params; i row.role + ':' + row.text.replace(/\\s+/g, ' ').trim()), + settled: settledRows.map(row => row.role + ':' + row.text.replace(/\\s+/g, ' ').trim()) +}})); +""" + result = _run_node_script(script) + + assert result["live"] == [ + "prose:same process prose", + "thinking:same process prose", + "prose:new process prose", + ] + assert result["settled"] == [ + "prose:same process prose", + "prose:same process prose", + "thinking:same process prose", + "prose:new process prose", + ] def test_live_anchor_scene_removes_legacy_interim_collapse_toggle(): @@ -752,6 +818,7 @@ def test_settled_anchor_scene_final_answer_does_not_fold_into_worklog_source(): "if(m._activityBurstId!==undefined||m._liveSegmentSeq!==undefined) return true;" ) assert "seg.classList.add('assistant-segment-worklog-source')" in render + assert "seg.hidden=true" in render assert "_renderSettledAnchorSceneForMessage(msg, seg, rawIdx)" in render @@ -763,6 +830,7 @@ def test_settled_anchor_scene_hides_prior_process_segments_not_final_answer(): assert "idx Date: Tue, 23 Jun 2026 11:13:33 -0400 Subject: [PATCH 35/45] fix(sidebar): reject cached fallback rows from the wrong hidden-filter mode (#4775) --- static/sessions.js | 11 +++++- tests/test_4167_sidebar_payload_and_scope.py | 26 ++++--------- .../test_issue4766_sidebar_source_pushdown.py | 39 ++++++++++++------- ...75_sidebar_hidden_zero_message_pushdown.py | 2 + 4 files changed, 45 insertions(+), 33 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 8b2a2c0a..9b47db59 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1637,10 +1637,14 @@ function _requestedSessionSidebarSource() { return window._showCliSessions ? _sessionSourceFilter : 'webui'; } +function _sessionListExcludeHiddenEnabled() { + return _activeProject===null || _activeProject===NO_PROJECT_FILTER; +} + function _sessionListQueryString() { const qs = new URLSearchParams(); qs.set('sidebar_source', _requestedSessionSidebarSource()); - if(_activeProject===null || _activeProject===NO_PROJECT_FILTER) qs.set('exclude_hidden','1'); + if(_sessionListExcludeHiddenEnabled()) qs.set('exclude_hidden','1'); if(_showAllProfiles) qs.set('all_profiles','1'); if(_showArchived) qs.set('include_archived','1'); return `?${qs.toString()}`; @@ -3882,6 +3886,7 @@ function _applySessionListPayload(sessData, projData){ : (S.activeProfile || 'default'), allProfiles: !!_showAllProfiles, sidebarSource: _requestedSessionSidebarSource(), + excludeHidden: _sessionListExcludeHiddenEnabled(), }; _syncSessionAttentionSoundState(_allSessions); _pruneLineageReportCacheToVisibleSessions(_allSessions); @@ -3987,11 +3992,13 @@ async function _runRenderSessionListRefresh(opts, _gen){ profile: S.activeProfile || 'default', allProfiles: !!_showAllProfiles, sidebarSource: _requestedSessionSidebarSource(), + excludeHidden: _sessionListExcludeHiddenEnabled(), }; const _scopeMatches = _allSessionsScope && _allSessionsScope.profile === _curScope.profile && _allSessionsScope.allProfiles === _curScope.allProfiles - && _allSessionsScope.sidebarSource === _curScope.sidebarSource; + && _allSessionsScope.sidebarSource === _curScope.sidebarSource + && _allSessionsScope.excludeHidden === _curScope.excludeHidden; // #4671: the /api/sessions fetch failed — clear the skeleton flag so this error // render (matched cache, or empty rows for a mismatched scope) replaces the // up-front profile-switch skeleton instead of stranding it. diff --git a/tests/test_4167_sidebar_payload_and_scope.py b/tests/test_4167_sidebar_payload_and_scope.py index 1f0bd41e..295dba40 100644 --- a/tests/test_4167_sidebar_payload_and_scope.py +++ b/tests/test_4167_sidebar_payload_and_scope.py @@ -1,16 +1,5 @@ -"""Regression tests for #4167 review items — sidebar payload allowlist must -preserve read-only + gateway-routing fields, and the failed-refresh path must -not re-render a prior profile's sessions after a profile switch. +"""Regression tests for #4167 sidebar payload and scope guards.""" -Item 1/2: _SIDEBAR_SESSION_RESPONSE_FIELDS must include read_only / is_read_only -(so the sidebar suppresses rename / action-menu / swipe on read-only sessions) -and gateway_routing (so the model label renders). Dropping these silently -regressed both surfaces. - -Item 3: renderSessionList()'s catch path must scope-tag the cache and clear it -when the requested profile scope differs, instead of unconditionally -re-rendering the cached (prior-profile) rows. -""" import re import sys from pathlib import Path @@ -35,7 +24,6 @@ def test_sidebar_response_item_preserves_read_only_flag(): fn = getattr(routes, "_sidebar_session_response_item", None) if fn is None: - # Helper name differs across versions — fall back to allowlist assertion. assert "read_only" in routes._SIDEBAR_SESSION_RESPONSE_FIELDS return row = fn({ @@ -51,12 +39,14 @@ def test_sidebar_response_item_preserves_read_only_flag(): def test_failed_refresh_clears_cache_on_profile_scope_change(): - """The catch path in renderSessionList must clear stale rows when the - requested scope differs from the cached scope (no cross-profile leak).""" + """The catch path must reject cached rows from a mismatched sidebar scope.""" src = (_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") - # The cache must be scope-tagged. assert "_allSessionsScope" in src, "cache is not scope-tagged" - # The catch path must compare current scope to the cached scope and clear - # _allSessions when they differ rather than always rendering from cache. assert re.search(r"_scopeMatches", src), "catch path does not gate fallback on scope match" assert re.search(r"_allSessions\s*=\s*\[\]", src), "catch path does not clear stale rows on scope mismatch" + assert "excludeHidden: _sessionListExcludeHiddenEnabled()" in src, ( + "scope tagging must include the hidden-filter query mode" + ) + assert "_allSessionsScope.excludeHidden === _curScope.excludeHidden" in src, ( + "catch path must reject cached rows fetched under the wrong hidden-filter mode" + ) diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index e1df9628..aafac1f1 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -250,6 +250,7 @@ def test_frontend_sends_sidebar_source_param(): def test_session_list_query_string_respects_sidebar_source_and_flags(): src = SESSIONS_JS.read_text(encoding="utf-8") requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled") query_fn = _extract_function(src, "_sessionListQueryString") script = f""" global.window = {{ _showCliSessions: true }}; @@ -258,6 +259,7 @@ global._sessionSourceFilter = 'cli'; global._showAllProfiles = true; global._showArchived = false; {requested_source_fn} +{exclude_hidden_fn} {query_fn} const first = _sessionListQueryString(); window._showCliSessions = false; @@ -325,6 +327,7 @@ console.log(JSON.stringify({{ def test_apply_payload_and_tab_count_helpers_cover_old_and_new_payloads(): src = SESSIONS_JS.read_text(encoding="utf-8") requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled") apply_fn = _extract_function(src, "_applySessionListPayload") count_fn = _extract_function(src, "_sessionSourceTabCount") clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") @@ -345,6 +348,8 @@ global._sessionListLoadError = null; global._sessionListHasLoadedOnce = false; global._sessionListFirstRenderAnimated = true; global._sessionListSkeletonActive = true; +global._activeProject = null; +global.NO_PROJECT_FILTER = '__none__'; global._showAllProfiles = false; global._sessionSourceFilter = 'webui'; global.S = {{ activeProfile: 'default' }}; @@ -358,13 +363,14 @@ global.startStreamingPoll = () => {{}}; global.stopStreamingPoll = () => {{}}; global.ensureSessionTimeRefreshPoll = () => {{}}; global.ensureActiveSessionExternalRefreshPoll = () => {{}}; -global.ensureSessionEventsSSE = () => {{}}; -global.animateNextSessionListRefresh = () => {{}}; -global.renderSessionListFromCache = () => {{}}; -{clear_fn} -{count_fn} -{requested_source_fn} -{apply_fn} + global.ensureSessionEventsSSE = () => {{}}; + global.animateNextSessionListRefresh = () => {{}}; + global.renderSessionListFromCache = () => {{}}; + {clear_fn} + {count_fn} + {requested_source_fn} + {exclude_hidden_fn} + {apply_fn} const sessions = [{{ session_id: 'webui-1' }}]; _applySessionListPayload({{ sessions, other_profile_count: 0, archived_count: 0, active_profile: 'default' }}, {{ projects: [] }}); const oldPayload = {{ @@ -520,6 +526,7 @@ def test_scope_mismatch_error_path_respects_sidebar_source(): purge_fn = _extract_function(src, "_purgeStaleInflightEntries") clear_fn = _extract_function(src, "_clearSessionSourceTabCounts") requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled") query_fn = _extract_function(src, "_sessionListQueryString") refresh_fn = _extract_function(src, "_runRenderSessionListRefresh").replace( "function _runRenderSessionListRefresh", @@ -537,6 +544,8 @@ global._profileSwitchListEmbargo = false; global._pendingSessionListPayload = null; global._allProjects = []; global._contentSearchResults = ['stale']; +global._activeProject = null; +global.NO_PROJECT_FILTER = '__none__'; global.S = {{ activeProfile: 'default' }}; global.$ = () => ({{ value: '' }}); global._isSessionListUserInteracting = () => false; @@ -558,12 +567,13 @@ global.renderSessionListFromCache = () => {{ }}); }}; global.api = () => Promise.reject(new Error('boom')); -global.clearInflightState = sid => cleared.push(sid); -{purge_fn} -{clear_fn} -{requested_source_fn} -{query_fn} -{refresh_fn} + global.clearInflightState = sid => cleared.push(sid); + {purge_fn} + {clear_fn} + {requested_source_fn} + {exclude_hidden_fn} + {query_fn} + {refresh_fn} async function runCase(requestedSource, cachedSource) {{ global._sessionSourceFilter = requestedSource; global._allSessions = [{{ session_id: cachedSource + '-1' }}]; @@ -571,6 +581,7 @@ async function runCase(requestedSource, cachedSource) {{ profile: 'default', allProfiles: false, sidebarSource: cachedSource, + excludeHidden: true, }}; global._sessionListSourceById = new Map([['webui-live', 'webui']]); global.INFLIGHT = {{ 'webui-live': {{ lastAssistantText: 'working' }} }}; @@ -609,6 +620,7 @@ async function runCase(requestedSource, cachedSource) {{ "profile": "default", "allProfiles": False, "sidebarSource": "cli", + "excludeHidden": True, } assert body["mismatch"]["webui"] is None assert body["mismatch"]["cli"] is None @@ -622,6 +634,7 @@ async function runCase(requestedSource, cachedSource) {{ "profile": "default", "allProfiles": False, "sidebarSource": "webui", + "excludeHidden": True, } assert body["match"]["webui"] == 11 assert body["match"]["cli"] == 5 diff --git a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py index b1cc76ff..7b0b87d5 100644 --- a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py +++ b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py @@ -245,6 +245,7 @@ def test_omit_exclude_hidden_still_returns_default_hidden_rows(monkeypatch): def test_default_and_unassigned_queries_send_exclude_hidden(monkeypatch): src = SESSIONS_JS.read_text(encoding="utf-8") requested_source_fn = _extract_function(src, "_requestedSessionSidebarSource") + exclude_hidden_fn = _extract_function(src, "_sessionListExcludeHiddenEnabled") project_filter_fn = _extract_function(src, "_setActiveProjectFilter") query_fn = _extract_function(src, "_sessionListQueryString") script = f""" @@ -257,6 +258,7 @@ global.NO_PROJECT_FILTER = '__none__'; global.renderSessionListFromCache = () => {{}}; global.renderSessionList = () => Promise.resolve(); {requested_source_fn} +{exclude_hidden_fn} {project_filter_fn} {query_fn} const default_query = _sessionListQueryString(); From ba6349bdcbaa7829b6e07889681cad9624bc0817 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 23 Jun 2026 17:19:50 +0200 Subject: [PATCH 36/45] fix: reset scroll state on session switch to prevent stale unpin blocking scroll-to-bottom --- static/sessions.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/static/sessions.js b/static/sessions.js index f01741bc..d8182a34 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1002,6 +1002,13 @@ async function loadSession(sid){ // Mark this session as the in-flight load. Subsequent loadSession() calls // will overwrite this; stale awaits use the mismatch to bail out (#1060). _loadingSessionId = sid; + // Reset scroll state for fresh session navigation — the reader expects to + // land at the bottom of the new transcript, not wherever a stale unpin flag + // from a prior session or a stray touch event during loading would place them. + if (currentSid !== sid && typeof _messageUserUnpinned !== 'undefined') { + _messageUserUnpinned = false; + _scrollPinned = true; + } stopApprovalPolling();hideApprovalCard(forceReload); if(typeof stopSessionStream==='function') stopSessionStream(); _yoloEnabled=false;_updateYoloPill(); From 75016c99f6879a1df9434b03ea07f280ce270764 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 11:46:37 -0400 Subject: [PATCH 37/45] test(sidebar): align legacy list checks with visible-session filtering (#4775) --- tests/test_gateway_sync.py | 23 +++++++++++++++++++ tests/test_regressions.py | 20 ++++++++++++++++ .../test_session_import_cli_fallback_model.py | 2 ++ tests/test_session_summary_redaction.py | 2 +- tests/test_sprint12.py | 12 +++++++++- tests/test_sprint15.py | 12 +++++++++- tests/test_sprint23.py | 20 ++++++++++++++++ 7 files changed, 88 insertions(+), 3 deletions(-) diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index bcb002c3..a5c164c6 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -2330,6 +2330,29 @@ def test_importing_older_gateway_session_preserves_original_timestamps_and_order {'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'}, ) assert rename_status == 200, rename + from api.models import Session + from tests.conftest import TEST_WORKSPACE + newer_webui_session = Session( + session_id=newer_webui_sid, + title='Newer WebUI Session', + workspace=str(TEST_WORKSPACE), + model='openai/gpt-5', + created_at=newer_webui['session']['created_at'], + updated_at=time.time(), + profile='default', + messages=[{ + 'role': 'user', + 'content': 'newer visible row', + 'timestamp': time.time(), + }], + tool_calls=[], + ) + newer_webui_session.save(touch_updated_at=False) + rename_refresh, rename_refresh_status = post( + '/api/session/rename', + {'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'}, + ) + assert rename_refresh_status == 200, rename_refresh _insert_gateway_session( conn, diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 3fc3ffcb..84376955 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -42,6 +42,24 @@ def make_session(created_list): return sid +def _make_session_visible(sid): + from api.models import Session + from tests.conftest import TEST_WORKSPACE + + session = Session( + session_id=sid, + title="regression-test-delete-R8", + workspace=str(TEST_WORKSPACE), + model="test", + created_at=time.time(), + updated_at=time.time(), + profile="default", + messages=[{"role": "user", "content": "visible row", "timestamp": time.time()}], + tool_calls=[], + ) + session.save(touch_updated_at=False) + + def _make_auth_json_with_credential_pool( provider_id: str, pool_entries: list[dict], tmp_dir: pathlib.Path ) -> pathlib.Path: @@ -296,6 +314,8 @@ def test_deleted_session_does_not_appear_in_list(cleanup_test_sessions): d, _ = post("/api/session/new", {}) sid = d["session"]["session_id"] post("/api/session/rename", {"session_id": sid, "title": "regression-test-delete-R8"}) + _make_session_visible(sid) + post("/api/session/rename", {"session_id": sid, "title": "regression-test-delete-R8"}) # Verify it appears sessions, _ = get("/api/sessions") diff --git a/tests/test_session_import_cli_fallback_model.py b/tests/test_session_import_cli_fallback_model.py index a053eb87..a43e0eb5 100644 --- a/tests/test_session_import_cli_fallback_model.py +++ b/tests/test_session_import_cli_fallback_model.py @@ -338,6 +338,7 @@ def test_sessions_endpoint_suppresses_duplicate_webui_state_projection(monkeypat "session_id": "visible_tip", "title": "Long Conversation", "profile": "default", + "message_count": 1, "updated_at": 20, "last_message_at": 20, "source_tag": "webui", @@ -362,6 +363,7 @@ def test_sessions_endpoint_suppresses_duplicate_webui_state_projection(monkeypat "session_id": "telegram_tip", "title": "External Thread", "profile": "default", + "message_count": 1, "updated_at": 10, "last_message_at": 10, "source_tag": "telegram", diff --git a/tests/test_session_summary_redaction.py b/tests/test_session_summary_redaction.py index e6a2c80f..7a07b456 100644 --- a/tests/test_session_summary_redaction.py +++ b/tests/test_session_summary_redaction.py @@ -34,7 +34,7 @@ def _write_session_with_secret_title(): created_at=now, updated_at=now, profile="default", - messages=[], + messages=[{"role": "user", "content": "visible row", "timestamp": now}], tool_calls=[], ) # Save through the model layer so the sidebar index is updated just like a diff --git a/tests/test_sprint12.py b/tests/test_sprint12.py index 2c7f3afe..b0dc4a79 100644 --- a/tests/test_sprint12.py +++ b/tests/test_sprint12.py @@ -29,6 +29,15 @@ def make_session(created_list): return sid +def _make_session_visible(sid): + from api.models import Session + + session = Session.load(sid) + assert session is not None + session.messages = [{"role": "user", "content": "visible row", "timestamp": 1.0}] + session.save() + + # ── Settings API ────────────────────────────────────────────────────────── def test_settings_get_returns_defaults(): @@ -121,8 +130,9 @@ def test_pinned_in_session_list(): created = [] try: sid = make_session(created) - # Pin it and give it a title so it shows in the list + # Give it one real message so the default sidebar route keeps it visible. post("/api/session/rename", {"session_id": sid, "title": "Pinned Test"}) + _make_session_visible(sid) post("/api/session/pin", {"session_id": sid, "pinned": True}) d, _ = get("/api/sessions") match = [s for s in d['sessions'] if s['session_id'] == sid] diff --git a/tests/test_sprint15.py b/tests/test_sprint15.py index 8fb06788..0b291f34 100644 --- a/tests/test_sprint15.py +++ b/tests/test_sprint15.py @@ -29,6 +29,15 @@ def make_session(created_list): return sid, d["session"] +def _make_session_visible(sid): + from api.models import Session + + session = Session.load(sid) + assert session is not None + session.messages = [{"role": "user", "content": "visible row", "timestamp": 1.0}] + session.save() + + def make_project(created_list, name="Test Project", color=None): body = {"name": name} if color: @@ -196,8 +205,9 @@ def test_session_project_in_list(): try: pid, _ = make_project(pids, "Listed") sid, _ = make_session(sids) - # Give it a title so it shows in list (non-empty Untitled sessions are hidden) + # Give it one real message so the default sidebar route keeps it visible. post("/api/session/rename", {"session_id": sid, "title": "Project Test Session"}) + _make_session_visible(sid) post("/api/session/move", {"session_id": sid, "project_id": pid}) dl, _ = get("/api/sessions") match = [s for s in dl["sessions"] if s["session_id"] == sid] diff --git a/tests/test_sprint23.py b/tests/test_sprint23.py index 43909d53..d0f088be 100644 --- a/tests/test_sprint23.py +++ b/tests/test_sprint23.py @@ -30,6 +30,24 @@ def make_session(created_list): return sid, d["session"] +def _make_session_visible(sid): + from api.models import Session + from tests.conftest import TEST_WORKSPACE + + session = Session( + session_id=sid, + title="Compact Usage", + workspace=str(TEST_WORKSPACE), + model="test", + created_at=1.0, + updated_at=1.0, + profile="default", + messages=[{"role": "user", "content": "visible row", "timestamp": 1.0}], + tool_calls=[], + ) + session.save(touch_updated_at=False) + + # ── Session usage fields ───────────────────────────────────────────────── def test_new_session_has_usage_fields(): @@ -57,6 +75,8 @@ def test_session_compact_has_usage_fields(): try: sid, _ = make_session(created) post("/api/session/rename", {"session_id": sid, "title": "Compact Usage"}) + _make_session_visible(sid) + post("/api/session/rename", {"session_id": sid, "title": "Compact Usage"}) d, status = get("/api/sessions") assert status == 200 match = [s for s in d["sessions"] if s["session_id"] == sid] From 165433fea6e45185fd19223fbf81ba76391e9169 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 23 Jun 2026 11:56:25 -0400 Subject: [PATCH 38/45] Keep legacy sidebar tests aligned with visible-row filtering --- api/routes.py | 2 ++ ...ue2157_sessions_list_stale_stream_state.py | 1 + ...75_sidebar_hidden_zero_message_pushdown.py | 10 ++++--- tests/test_sprint1.py | 27 ++++++++++++++----- tests/test_sprint15.py | 18 +++++++------ 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/api/routes.py b/api/routes.py index 1915b7ec..10560356 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9839,6 +9839,8 @@ def handle_get(handler, parsed) -> bool: sidebar_source = parse_qs(parsed.query).get("sidebar_source", [""])[0].strip().lower() or None if sidebar_source not in ("webui", "cli"): sidebar_source = None + # /api/sessions is the default sidebar contract, so keep the route-owned + # visible-row filter in the shared cache builder for both cache hits and misses. key = _session_list_cache_key( active_profile=active_profile, all_profiles=all_profiles, diff --git a/tests/test_issue2157_sessions_list_stale_stream_state.py b/tests/test_issue2157_sessions_list_stale_stream_state.py index 86616745..b7d2308c 100644 --- a/tests/test_issue2157_sessions_list_stale_stream_state.py +++ b/tests/test_issue2157_sessions_list_stale_stream_state.py @@ -48,6 +48,7 @@ def test_sessions_list_reconciles_stale_stream_state_before_serializing(monkeypa "session_id": "stale-session", "title": "Stale Session", "profile": "default", + "message_count": 1, "active_stream_id": active_stream_id, "is_streaming": is_streaming, "updated_at": 1, diff --git a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py index 7b0b87d5..17850d2e 100644 --- a/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py +++ b/tests/test_issue4775_sidebar_hidden_zero_message_pushdown.py @@ -218,15 +218,19 @@ def test_zero_message_rows_with_visibility_signals_survive(monkeypatch): handler = _handle_sessions("http://example.com/api/sessions?sidebar_source=webui&exclude_hidden=1") body = handler.json_body() + by_id = {row["session_id"]: row for row in body["sessions"]} assert handler.status == 200 - assert [r["session_id"] for r in body["sessions"]] == [ - "visible-with-message", + assert set(by_id) >= { "active-stream-row", "attention-row", "pending-flag-row", "pending-user-row", - ] + } + assert by_id["active-stream-row"]["active_stream_id"] == "stream-active" + assert by_id["attention-row"]["attention"]["kind"] == "clarify" + assert by_id["pending-flag-row"]["has_pending_user_message"] is True + assert by_id["pending-user-row"]["title"] == "Pending user row" def test_omit_exclude_hidden_still_returns_default_hidden_rows(monkeypatch): diff --git a/tests/test_sprint1.py b/tests/test_sprint1.py index f11eb0e1..7f91729c 100644 --- a/tests/test_sprint1.py +++ b/tests/test_sprint1.py @@ -85,6 +85,16 @@ def make_session_tracked(created_list, ws=None): return sid, pathlib.Path(d["session"]["workspace"]) +def _make_session_visible(sid): + data, status = post("/api/chat/start", { + "session_id": sid, + "message": "visible row", + "model": "openai/gpt-5.4-mini", + }) + assert status == 200, f"chat/start failed with {status}: {data}" + get(f"/api/chat/cancel?stream_id={urllib.parse.quote(data['stream_id'])}") + + # ────────────────────────────────────────────── # Health check (prerequisite for all tests) @@ -124,7 +134,13 @@ def test_session_create_and_load(): sid = data["session"]["session_id"] assert len(sid) == 12 # uuid4().hex[:12] - # Give it a title so it's visible in the session list (empty Untitled sessions are filtered) + # Load it directly + loaded = get(f"/api/session?session_id={sid}") + assert loaded["session"]["session_id"] == sid + assert loaded["session"]["messages"] == [] + + # Give it a real message so the default sidebar route keeps it visible. + _make_session_visible(sid) post("/api/session/rename", {"session_id": sid, "title": "test-create-verify"}) # Verify it appears in /api/sessions list @@ -132,11 +148,6 @@ def test_session_create_and_load(): sids = [s["session_id"] for s in sessions["sessions"]] assert sid in sids, f"New session {sid} not in sessions list" - # Load it directly - loaded = get(f"/api/session?session_id={sid}") - assert loaded["session"]["session_id"] == sid - assert loaded["session"]["messages"] == [] - # Cleanup post("/api/session/delete", {"session_id": sid}) @@ -207,14 +218,16 @@ def test_session_delete_nonexistent(): def test_sessions_list_sorted(): """Sessions list should be sorted most-recently-updated first.""" - # Create two sessions with a title so they're visible (empty Untitled sessions are filtered) + # Create two sessions with a real message so the default sidebar route keeps them visible. a, _ = post("/api/session/new", {}) time.sleep(0.05) b, _ = post("/api/session/new", {}) sid_a = a["session"]["session_id"] sid_b = b["session"]["session_id"] + _make_session_visible(sid_a) post("/api/session/rename", {"session_id": sid_a, "title": "test-sort-a"}) time.sleep(0.05) + _make_session_visible(sid_b) post("/api/session/rename", {"session_id": sid_b, "title": "test-sort-b"}) sessions = get("/api/sessions") diff --git a/tests/test_sprint15.py b/tests/test_sprint15.py index 0b291f34..73185307 100644 --- a/tests/test_sprint15.py +++ b/tests/test_sprint15.py @@ -1,7 +1,7 @@ """ Sprint 15 Tests: session projects (CRUD, move, backward compat). """ -import json, urllib.error, urllib.request +import json, urllib.error, urllib.parse, urllib.request from tests._pytest_port import BASE @@ -30,12 +30,13 @@ def make_session(created_list): def _make_session_visible(sid): - from api.models import Session - - session = Session.load(sid) - assert session is not None - session.messages = [{"role": "user", "content": "visible row", "timestamp": 1.0}] - session.save() + d, status = post("/api/chat/start", { + "session_id": sid, + "message": "visible row", + "model": "openai/gpt-5.4-mini", + }) + assert status == 200, f"chat/start failed with {status}: {d}" + get(f"/api/chat/cancel?stream_id={urllib.parse.quote(d['stream_id'])}") def make_project(created_list, name="Test Project", color=None): @@ -226,7 +227,8 @@ def test_compact_includes_project_id(): sids = [] try: sid, sess = make_session(sids) - # Give it a title so it appears in the list + # Give it a real message so the default sidebar route keeps it visible. + _make_session_visible(sid) post("/api/session/rename", {"session_id": sid, "title": "Compat Test"}) dl, _ = get("/api/sessions") match = [s for s in dl["sessions"] if s["session_id"] == sid] From f4af6d84191d497d793b08a53f6ab2a659978c90 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 16:17:01 +0000 Subject: [PATCH 39/45] fix(#4778): scroll-rebuild guard must not reclassify a pinned follower as unpinned Codex regression gate caught: the guard predicate keyed on a raw messagesEl.scrollTop>0, so a pinned reader transiently at bottomDistance>250 mid-render (assistant body grows before the anchor scene re-renders) got flipped to _messageUserUnpinned=true/_scrollPinned=false and lost auto-follow. Require an explicit non-follow signal (_messageUserUnpinned || _scrollPinned===false) instead. Updated the existing guard test to a genuine unpinned reader + added a pinned-large-growth regression. Co-authored-by: franksong2702 --- static/ui.js | 7 ++- .../test_issue3479_ios_stream_scroll_jump.py | 2 +- .../test_issue4295_midstream_scroll_anchor.py | 48 ++++++++++++++++++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/static/ui.js b/static/ui.js index 22a0dd90..a7b23cef 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9595,7 +9595,12 @@ function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){ const messagesEl=$('messages'); if(!messagesEl||!scrollSnapshot) return {readerAwayFromBottom:false,release:null}; const beforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight); - const readerAwayFromBottom=beforeBottomDistance>250&&(_messageUserUnpinned||messagesEl.scrollTop>0); + // Only treat the reader as away if they were ALREADY in a non-follow state. + // A pinned follower can transiently have bottomDistance>250 mid-render (the + // assistant body grows before the anchor scene re-renders), so keying on a + // raw scrollTop>0 here would mis-classify a pinned reader as unpinned and kill + // auto-follow. Require an explicit unpin/non-pinned signal instead. + const readerAwayFromBottom=beforeBottomDistance>250&&(_messageUserUnpinned||_scrollPinned===false); if(!readerAwayFromBottom) return {readerAwayFromBottom:false,release:null}; scrollSnapshot.pinned=false; scrollSnapshot.userUnpinned=true; diff --git a/tests/test_issue3479_ios_stream_scroll_jump.py b/tests/test_issue3479_ios_stream_scroll_jump.py index 9246c7fd..3dbadce8 100644 --- a/tests/test_issue3479_ios_stream_scroll_jump.py +++ b/tests/test_issue3479_ios_stream_scroll_jump.py @@ -85,7 +85,7 @@ def test_live_anchor_worklog_rebuild_guards_height_for_unpinned_reader(): compact = _compact(guard) assert "constbeforeBottomDistance=Math.max(0,messagesEl.scrollHeight-messagesEl.scrollTop-messagesEl.clientHeight);" in compact - assert "beforeBottomDistance>250&&(_messageUserUnpinned||messagesEl.scrollTop>0)" in compact + assert "beforeBottomDistance>250&&(_messageUserUnpinned||_scrollPinned===false)" in compact assert "scrollSnapshot.pinned=false;" in compact assert "scrollSnapshot.userUnpinned=true;" in compact assert "scrollSnapshot.bottom=beforeBottomDistance;" in compact diff --git a/tests/test_issue4295_midstream_scroll_anchor.py b/tests/test_issue4295_midstream_scroll_anchor.py index fe817d4c..ae549ea4 100644 --- a/tests/test_issue4295_midstream_scroll_anchor.py +++ b/tests/test_issue4295_midstream_scroll_anchor.py @@ -112,8 +112,8 @@ def test_live_anchor_rebuild_guard_holds_height_and_marks_reader_unpinned(): script = f""" const assert = require('assert'); -let _messageUserUnpinned = false; -let _scrollPinned = true; +let _messageUserUnpinned = true; +let _scrollPinned = false; let _nearBottomCount = 2; const messages = {{ scrollTop: 2000, @@ -165,6 +165,50 @@ assert.strictEqual(Object.prototype.hasOwnProperty.call(inner.dataset, 'liveAnch subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) +def test_live_anchor_rebuild_guard_keeps_pinned_follower_pinned_on_large_growth(): + """A PINNED follower must NOT be reclassified as unpinned when a large live + render transiently pushes bottomDistance>250. Regression guard: the predicate + must require an explicit non-follow signal (_messageUserUnpinned / _scrollPinned + === false), not a raw scrollTop>0, or pinned live streams stop auto-following.""" + + script = f""" +const assert = require('assert'); +let _messageUserUnpinned = false; +let _scrollPinned = true; +let _nearBottomCount = 2; +const messages = {{ + scrollTop: 4400, + scrollHeight: 5500, + clientHeight: 600, +}}; +const inner = {{ style: {{ minHeight: '' }}, dataset: {{}} }}; +function $(id) {{ + if (id === 'messages') return messages; + if (id === 'msgInner') return inner; + return null; +}} +{_function_body(UI_JS, "_prepareLiveAnchorScrollRebuildGuard")} +const snapshot = {{ + top: 4400, + bottom: 0, + scrollHeight: 5500, + pinned: true, + userUnpinned: false, +}}; +const guard = _prepareLiveAnchorScrollRebuildGuard(snapshot); +// bottomDistance = 5500 - 4400 - 600 = 500 (>250), but the reader is pinned and +// has not manually unpinned -> must stay pinned, follow-scroll must NOT be skipped. +assert.strictEqual(guard.readerAwayFromBottom, false); +assert.strictEqual(guard.release, null); +assert.strictEqual(_messageUserUnpinned, false); +assert.strictEqual(_scrollPinned, true); +assert.strictEqual(snapshot.pinned, true); +assert.strictEqual(snapshot.userUnpinned, false); +assert.strictEqual(inner.style.minHeight, ''); +""" + subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + + def test_restore_message_scroll_snapshot_keeps_intermediate_distance_state(): script = f""" const assert = require('assert'); From 29ceed3a72b2ecb68d234b2e034e9b8f7b370979 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 16:19:50 +0000 Subject: [PATCH 40/45] fix(#4778): gate deferred rAF re-restore on still-unpinned (Opus gate) Opus caught a narrow timing race: if a reader re-pins between the scroll-rebuild guard engaging and its deferred rAF firing, the stale re-restore would yank them back off the bottom. Gate the re-restore on _messageUserUnpinned at rAF time; the minHeight release still always runs. Normal single-render unpinned case unaffected. --- static/ui.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/static/ui.js b/static/ui.js index a7b23cef..cedaca31 100644 --- a/static/ui.js +++ b/static/ui.js @@ -9682,7 +9682,10 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ if(scrollRebuildGuard&&scrollRebuildGuard.release){ requestAnimationFrame(()=>{ scrollRebuildGuard.release(); - _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); + // Only re-restore the unpinned snapshot if the reader is STILL unpinned at + // rAF time. If they re-pinned between guard-engage and this frame, the + // stale re-restore would yank them back off the bottom (Opus gate finding). + if(_messageUserUnpinned) _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); }); } if(!scrollRebuildGuard.readerAwayFromBottom&&typeof scrollIfPinned==='function') scrollIfPinned(); From 198e98ec74cf51408a30bcb025f96f15de6f4178 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 16:36:54 +0000 Subject: [PATCH 41/45] release: stamp v0.51.604 (live-stream worklog scroll/dedup + mobile session-switch scroll) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3f62a7..5c1b3d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## [Unreleased] +## [v0.51.604] — 2026-06-23 — Release VK (live-stream worklog scroll + dedup + mobile session-switch scroll) + ### Fixed - **Compact Worklog no longer snaps back to the user prompt during live-stream rebuilds.** Follow-up to the mid-stream scroll restore: the live Anchor scene now holds the transcript's scroll height stable while it removes and rebuilds Worklog rows, so the browser cannot temporarily clamp an unpinned reader's `scrollTop` to the top before the replacement rows land. Pinned-at-bottom streaming still follows the latest output. Thanks @franksong2702. (#4778) From 337cd95e32c5d055839e77ce56f889f2b62406a1 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 16:56:22 +0000 Subject: [PATCH 42/45] release: stamp v0.51.605 (#4775 trim hidden/empty sidebar payload rows) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1b3d67..dc991463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.605] — 2026-06-23 — Release VL (trim hidden/empty rows from the sidebar payload) + +### Changed + +- **The `/api/sessions` sidebar payload no longer ships rows the browser drops locally.** Building on the source-tab pushdown (#4769), the default sidebar path was still serializing rescued `default_hidden` rows and plain 0-message rows that carry no visible-sidebar signal, only for the browser to discard them. These are now trimmed server-side before serialization, while messageful rows, rows with attention/unread/running state, active-stream rows, and the named-project-chip rescue (#3019) are all preserved request-shaped. Reduces payload size and serialization cost on the profile-switch hot path. Thanks @rodboev. (#4775) + ## [v0.51.604] — 2026-06-23 — Release VK (live-stream worklog scroll + dedup + mobile session-switch scroll) ### Fixed From 50f7f7fcd3ef59208368c3f0197f206a255cea7f Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 17:13:22 +0000 Subject: [PATCH 43/45] test(#4741): add two #798 isolation regression guards (Opus gate hardening) - env-change-with-unchanged-mtime must re-expand (proves the cache stores raw un-expanded YAML, not the expanded result -> no stale cross-profile expansion) - no-cross-serve between two profile config paths (proves the parse cache is path-keyed). Both pass on the current impl; locks down the highest-risk cross-profile credential-bleed invariant per Opus's non-blocking note. --- ...est_issue4662_reload_config_mtime_cache.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/tests/test_issue4662_reload_config_mtime_cache.py b/tests/test_issue4662_reload_config_mtime_cache.py index 875f80c8..9bded3cf 100644 --- a/tests/test_issue4662_reload_config_mtime_cache.py +++ b/tests/test_issue4662_reload_config_mtime_cache.py @@ -78,6 +78,74 @@ def test_reload_config_expands_env_vars(tmp_path, monkeypatch): assert key == "expanded-value-xyz", f"env var not expanded after reload: {key!r}" +def test_reload_config_reexpands_env_when_mtime_unchanged(tmp_path, monkeypatch): + """#798 hardening (Opus gate): the mtime cache must store the RAW, un-expanded + YAML — not the expanded result — so that a ${VAR} re-expands against the CURRENT + os.environ on every reload even when config.yaml's mtime is unchanged. If the + cache stored the expanded value, a profile/env change with an unchanged file + would serve a stale expansion (cross-profile credential bleed).""" + import api.config as cfg + + config_path = tmp_path / "config.yaml" + config_path.write_text( + "providers:\n openai:\n api_key: ${HERMES_TEST_REEXPAND_TOKEN}\n", + encoding="utf-8", + ) + monkeypatch.setattr(cfg, "_get_config_path", lambda: config_path) + with cfg._yaml_file_cache_lock: + cfg._yaml_file_cache.clear() + + # First reload under env value A. + monkeypatch.setenv("HERMES_TEST_REEXPAND_TOKEN", "value-A") + cfg.reload_config() + key_a = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key") + assert key_a == "value-A", f"first expansion wrong: {key_a!r}" + + # Change ONLY the env var — the file (and its mtime) is untouched, so the YAML + # parse cache will hit. The expansion must still pick up the new env value. + monkeypatch.setenv("HERMES_TEST_REEXPAND_TOKEN", "value-B") + cfg.reload_config() + key_b = ((cfg.get_config().get("providers") or {}).get("openai") or {}).get("api_key") + assert key_b == "value-B", ( + f"env change with unchanged mtime did not re-expand (got {key_b!r}); " + "the mtime cache is storing the EXPANDED value instead of raw YAML " + "-> cross-profile credential-bleed risk (#798)" + ) + + +def test_reload_config_does_not_cross_serve_between_profile_paths(tmp_path, monkeypatch): + """#798 hardening (Opus gate): the YAML parse cache is keyed on the config PATH + (plus mtime+size), so two different profiles' config.yaml files must never serve + each other's parsed content — even within the same process. A path-blind cache + would leak one profile's providers/keys into another.""" + import api.config as cfg + + path_a = tmp_path / "profile_a" / "config.yaml" + path_b = tmp_path / "profile_b" / "config.yaml" + path_a.parent.mkdir(parents=True, exist_ok=True) + path_b.parent.mkdir(parents=True, exist_ok=True) + path_a.write_text("providers:\n openai:\n models: [model-a]\n", encoding="utf-8") + path_b.write_text("providers:\n anthropic:\n models: [model-b]\n", encoding="utf-8") + + with cfg._yaml_file_cache_lock: + cfg._yaml_file_cache.clear() + + monkeypatch.setattr(cfg, "_get_config_path", lambda: path_a) + cfg.reload_config() + providers_a = cfg.get_config().get("providers") or {} + assert "openai" in providers_a and "anthropic" not in providers_a, ( + f"profile A served the wrong config: {sorted(providers_a)}" + ) + + monkeypatch.setattr(cfg, "_get_config_path", lambda: path_b) + cfg.reload_config() + providers_b = cfg.get_config().get("providers") or {} + assert "anthropic" in providers_b and "openai" not in providers_b, ( + f"profile B was cross-served profile A's config (cache not path-keyed): " + f"{sorted(providers_b)}" + ) + + def test_reload_config_empty_dict_config_does_not_spin(tmp_path, monkeypatch): """An empty ``{}`` config must still stamp _cfg_mtime, or get_config()'s `current_mtime != _cfg_mtime` stale check fires forever and re-enters From 18e252d59c6d6d68f8d6ca591f8c5c1edb4c6ced Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 17:17:17 +0000 Subject: [PATCH 44/45] test(#4741): update #4766 mock to accept the new redact_enabled kwarg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4741 added redact_enabled= to _sidebar_session_response_item's call site (routes.py:2199); the pre-existing #4766 test mocked it with a bare 'lambda row: row' that rejects the kwarg -> full-suite TypeError. Mock now accepts the keyword-only arg. Not a weakened assertion — the test still asserts bucket-count omission. --- tests/test_issue4766_sidebar_source_pushdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_issue4766_sidebar_source_pushdown.py b/tests/test_issue4766_sidebar_source_pushdown.py index aafac1f1..8cb45723 100644 --- a/tests/test_issue4766_sidebar_source_pushdown.py +++ b/tests/test_issue4766_sidebar_source_pushdown.py @@ -499,7 +499,7 @@ console.log(JSON.stringify({{ def test_session_list_response_omits_bucket_counts_when_missing(monkeypatch): monkeypatch.setattr(routes, "_session_list_cache_overlay_runtime_rows", lambda rows: rows) - monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row: row) + monkeypatch.setattr(routes, "_sidebar_session_response_item", lambda row, *, redact_enabled=None: row) body = routes._session_list_payload_to_response( { From f9a687d01dd7686410041c7692548aa437f1bf3a Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 23 Jun 2026 17:27:06 +0000 Subject: [PATCH 45/45] release: stamp v0.51.606 (#4662 phase 2+3 backend hot-path caching) --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c135ee59..f46c36d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ ## [Unreleased] +## [v0.51.606] — 2026-06-23 — Release VM (backend hot-path caching: config reparse + sidebar redaction read-once) + ### Changed -- **Faster profile switching: the backend stops re-reading config and settings on the hot path.** Two behavior-preserving caching wins on the `/api/sessions` path that a profile switch hits cold (#4662, phase 2+3). (1) `reload_config()` no longer re-parses `config.yaml` from disk when the file is unchanged — it now routes through the same mtime-keyed parse cache used elsewhere, removing a ~hundreds-of-ms YAML reparse from each switch while preserving the exact process-env-expansion semantics that keep per-client profile isolation correct. (2) The sidebar session-list response now reads the redaction setting once per response instead of once per conversation row, eliminating a per-row `settings.json` read that grew into a multi-second serialization stage on large session lists. No user-visible behavior change — redaction still applies exactly as before; switching is just quicker. Part of the phased profile-switch performance work. (#4662) +- **Faster profile switching: the backend stops re-reading config and settings on the hot path.** Two behavior-preserving caching wins on the `/api/sessions` path that a profile switch hits cold (#4662, phase 2+3). (1) `reload_config()` no longer re-parses `config.yaml` from disk when the file is unchanged — it now routes through the same mtime-keyed parse cache used elsewhere, removing a ~hundreds-of-ms YAML reparse from each switch while preserving the exact process-env-expansion semantics that keep per-client profile isolation correct. (2) The sidebar session-list response now reads the redaction setting once per response instead of once per conversation row, eliminating a per-row `settings.json` read that grew into a multi-second serialization stage on large session lists. No user-visible behavior change — redaction still applies exactly as before; switching is just quicker. Part of the phased profile-switch performance work. Malformed `config.yaml` is now treated as empty-config-plus-defaults (mtime-stamped, no reload spin) rather than raising on every call. (#4662) ## [v0.51.605] — 2026-06-23 — Release VL (trim hidden/empty rows from the sidebar payload)