diff --git a/docs/pr-media/1792/sidebar-first-turn-click-away-fixed.png b/docs/pr-media/1792/sidebar-first-turn-click-away-fixed.png new file mode 100644 index 00000000..b51cdaab Binary files /dev/null and b/docs/pr-media/1792/sidebar-first-turn-click-away-fixed.png differ diff --git a/static/sessions.js b/static/sessions.js index a925b1c8..7ee8214e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1450,6 +1450,53 @@ window.addEventListener('resize',()=>{ // with stale data, causing sessions to vanish from the sidebar. let _renderSessionListGen = 0; +function _isOptimisticFirstTurnSessionRow(s){ + if(!s||!s.session_id||s.archived) return false; + const messageCount=Number(s.message_count||0); + if(messageCount<=0&&!s.pending_user_message) return false; + return Boolean( + s.is_streaming|| + s.active_stream_id|| + s.pending_user_message|| + s.pending_started_at|| + _isSessionLocallyStreaming(s)|| + _sessionStreamingById.get(s.session_id)===true + ); +} + +function _mergeOptimisticFirstTurnSessions(fetchedSessions){ + const merged=Array.isArray(fetchedSessions)?[...fetchedSessions]:[]; + const bySid=new Map(); + merged.forEach((s,idx)=>{if(s&&s.session_id) bySid.set(s.session_id,idx);}); + for(const local of Array.isArray(_allSessions)?_allSessions:[]){ + if(!_isOptimisticFirstTurnSessionRow(local)) continue; + const sid=local.session_id; + const idx=bySid.has(sid)?bySid.get(sid):-1; + if(idx>=0){ + const fetched=merged[idx]||{}; + const localCount=Number(local.message_count||0); + const fetchedCount=Number(fetched.message_count||0); + const localTs=Number(local.last_message_at||local.updated_at||0); + const fetchedTs=Number(fetched.last_message_at||fetched.updated_at||0); + merged[idx]={ + ...local, + ...fetched, + message_count:Math.max(localCount,fetchedCount), + last_message_at:Math.max(localTs,fetchedTs), + updated_at:Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs), + active_stream_id:fetched.active_stream_id||local.active_stream_id||null, + pending_user_message:fetched.pending_user_message||local.pending_user_message||null, + pending_started_at:fetched.pending_started_at||local.pending_started_at||null, + is_streaming:Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)), + }; + }else{ + merged.push({...local,is_streaming:true}); + bySid.set(sid,merged.length-1); + } + } + return merged; +} + async function renderSessionList(){ const _gen = ++_renderSessionListGen; try{ @@ -1465,7 +1512,7 @@ async function renderSessionList(){ // active profile so the "Show N from other profiles" toggle can render // without a second round-trip. Stashed on the module for renderSessionListFromCache. _otherProfileCount = sessData.other_profile_count || 0; - _allSessions = sessData.sessions||[]; + _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); _allProjects = projData.projects||[]; // Capture server clock for clock-skew compensation (issue #1144). // server_time is epoch seconds from the server's time.time(). diff --git a/tests/test_sidebar_first_turn_visibility.py b/tests/test_sidebar_first_turn_visibility.py index 0e2395a6..f1256d16 100644 --- a/tests/test_sidebar_first_turn_visibility.py +++ b/tests/test_sidebar_first_turn_visibility.py @@ -97,3 +97,20 @@ class TestSidebarFirstTurnVisibility: "The index-path empty-session filter must exempt pending first-turn sessions, " "matching the full-scan fallback." ) + + def test_session_refresh_preserves_optimistic_first_turn_rows_when_server_lags(self): + src = read("static/sessions.js") + assert "function _mergeOptimisticFirstTurnSessions" in src, ( + "renderSessionList() must merge locally optimistically inserted first-turn rows " + "back into the fetched /api/sessions result. A session switch can re-fetch before " + "the server has saved pending state, and replacing _allSessions would hide the " + "new in-flight chat until the stream finishes." + ) + render_start = src.index("async function renderSessionList") + render_end = src.index("// ── Gateway session SSE", render_start) + render_body = src[render_start:render_end] + assign_idx = render_body.index("_allSessions =") + assert "_mergeOptimisticFirstTurnSessions" in render_body[:assign_idx + 160], ( + "The fetched session list should be merged with optimistic rows at the assignment " + "site, before completion transitions or renderSessionListFromCache() run." + )