From f1b2a21bd4e2b9b75e64a439529941332ae456ab Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Tue, 12 May 2026 18:02:49 +0800 Subject: [PATCH] feat: lazy-load full lineage segments --- static/sessions.js | 82 ++++++++++++++- tests/test_session_lineage_collapse.py | 132 +++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 3 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 89d7e792..4c0d821c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1272,6 +1272,9 @@ let _sessionActionAnchor = null; let _sessionActionSessionId = null; const _expandedChildSessionKeys = new Set(); const _expandedLineageKeys = new Set(); +const _lineageReportCache = new Map(); +const _lineageReportInflight = new Map(); +let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; @@ -1758,6 +1761,7 @@ async function renderSessionList(){ // without a second round-trip. Stashed on the module for renderSessionListFromCache. _otherProfileCount = sessData.other_profile_count || 0; _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); + _clearLineageReportCache(); _allProjects = projData.projects||[]; // Capture server clock for clock-skew compensation (issue #1144). // server_time is epoch seconds from the server's time.time(). @@ -2095,6 +2099,73 @@ function _sessionSegmentCount(s){ return count>1?count:0; } +function _clearLineageReportCache(){ + _lineageReportCache.clear(); + _lineageReportInflight.clear(); + _lineageReportCacheGeneration++; +} + +function _lineageReportCacheKey(s,lineageKey){ + return lineageKey||_sidebarLineageKeyForRow(s)||null; +} + +function _lineageLocalSegmentCount(s){ + if(!s) return 0; + if(Array.isArray(s._lineage_segments)) return s._lineage_segments.length; + return s.session_id?1:0; +} + +function _lineageReportNeedsFetch(s,lineageKey,segmentCount){ + const key=_lineageReportCacheKey(s,lineageKey); + if(!s||!s.session_id||!key) return false; + if(_lineageReportCache.has(key)||_lineageReportInflight.has(key)) return false; + return Number(segmentCount||0)>_lineageLocalSegmentCount(s); +} + +function _lineageSegmentsForRender(s,lineageKey){ + const segments=[]; + const seen=new Set(); + const currentSid=s&&s.session_id; + const addSegment=(seg)=>{ + if(!seg||!seg.session_id||seg.session_id===currentSid||seen.has(seg.session_id)) return; + if(seg.role==='child_session') return; + seen.add(seg.session_id); + segments.push({...seg}); + }; + for(const seg of (Array.isArray(s&&s._lineage_segments)?s._lineage_segments:[])) addSegment(seg); + const cached=_lineageReportCache.get(_lineageReportCacheKey(s,lineageKey)); + if(cached&&Array.isArray(cached.segments)){ + for(const seg of cached.segments) addSegment(seg); + } + return segments; +} + +function _fetchLineageReportForRow(s,lineageKey){ + const key=_lineageReportCacheKey(s,lineageKey); + if(!s||!s.session_id||!key) return Promise.resolve(null); + if(_lineageReportCache.has(key)) return Promise.resolve(_lineageReportCache.get(key)); + if(_lineageReportInflight.has(key)) return _lineageReportInflight.get(key); + const generation=_lineageReportCacheGeneration; + let request; + request=api('/api/session/lineage/report?session_id='+encodeURIComponent(s.session_id)) + .then(report=>{ + if(generation===_lineageReportCacheGeneration){ + _lineageReportCache.set(key,(report&&report.found!==false)?report:{error:true}); + } + return report; + }) + .catch(err=>{ + console.warn('lineage report',err); + if(generation===_lineageReportCacheGeneration) _lineageReportCache.set(key,{error:true}); + return null; + }) + .finally(()=>{ + if(_lineageReportInflight.get(key)===request) _lineageReportInflight.delete(key); + }); + _lineageReportInflight.set(key,request); + return request; +} + function _sidebarLineageKeyForRow(s){ if(!s) return null; return s._lineage_key||s._lineage_root_id||s.lineage_root_id||s.parent_session_id||s.session_id||null; @@ -2703,8 +2774,10 @@ function renderSessionListFromCache(){ } const lineageKey=_sidebarLineageKeyForRow(s); const segmentCount=_sessionSegmentCount(s); - const lineageSegments=Array.isArray(s._lineage_segments)?s._lineage_segments.filter(seg=>seg&&seg.session_id&&seg.session_id!==s.session_id):[]; - const canExpandLineageSegments=Boolean(lineageKey&&segmentCount>1&&lineageSegments.length>0); + const lineageSegments=_lineageSegmentsForRender(s,lineageKey); + const needsLineageReport=_lineageReportNeedsFetch(s,lineageKey,segmentCount); + const lineageReportKey=_lineageReportCacheKey(s,lineageKey); + const canExpandLineageSegments=Boolean(lineageKey&&segmentCount>1&&(lineageSegments.length>0||needsLineageReport||_lineageReportInflight.has(lineageReportKey))); const lineageSegmentsExpanded=canExpandLineageSegments&&_expandedLineageKeys.has(lineageKey); if(segmentCount>0){ const segmentCountEl=document.createElement('span'); @@ -2721,7 +2794,10 @@ function renderSessionListFromCache(){ e.preventDefault(); e.stopPropagation(); if(_expandedLineageKeys.has(lineageKey)) _expandedLineageKeys.delete(lineageKey); - else _expandedLineageKeys.add(lineageKey); + else { + _expandedLineageKeys.add(lineageKey); + if(needsLineageReport) _fetchLineageReportForRow(s,lineageKey).then(()=>renderSessionListFromCache()); + } renderSessionListFromCache(); }; segmentCountEl.onclick=toggleLineageSegments; diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 0d14938a..fd9c7e76 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -338,11 +338,16 @@ def test_lineage_segment_expansion_static_contract(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") assert "const _expandedLineageKeys = new Set();" in js + assert "const _lineageReportCache = new Map();" in js + assert "const _lineageReportInflight = new Map();" in js assert "session-lineage-count,.session-lineage-segments,.session-lineage-segment" in js assert "segmentCountEl.setAttribute('aria-expanded'" in js assert "_expandedLineageKeys.has(lineageKey)" in js assert "_expandedLineageKeys.add(lineageKey)" in js assert "_expandedLineageKeys.delete(lineageKey)" in js + assert "_fetchLineageReportForRow(s,lineageKey).then" in js + assert "'/api/session/lineage/report?session_id='" in js + assert "encodeURIComponent(s.session_id)" in js assert "className='session-lineage-segments'" in js assert "className='session-lineage-segment'" in js assert "const segTitle=seg.title||t('session_lineage_segment_untitled');" in js @@ -354,6 +359,133 @@ def test_lineage_segment_expansion_static_contract(): assert ".session-lineage-segment{" in css +def test_lineage_report_fetch_is_needed_only_when_backend_count_exceeds_materialized_segments(): + 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); +}} +const _lineageReportCache = new Map(); +const _lineageReportInflight = new Map(); +eval(extractFunc('_lineageReportCacheKey')); +eval(extractFunc('_lineageLocalSegmentCount')); +eval(extractFunc('_lineageReportNeedsFetch')); +const backendOnly = {{session_id:'tip', _lineage_key:'root', _compression_segment_count:25}}; +const localFull = {{ + session_id:'tip', + _lineage_key:'root', + _compression_segment_count:2, + _lineage_segments:[{{session_id:'tip'}}, {{session_id:'root'}}], +}}; +const before = _lineageReportNeedsFetch(backendOnly, 'root', 25); +_lineageReportCache.set('root', {{segments:[{{session_id:'tip'}}, {{session_id:'root'}}]}}); +const afterCache = _lineageReportNeedsFetch(backendOnly, 'root', 25); +const fullLocal = _lineageReportNeedsFetch(localFull, 'root', 2); +console.log(JSON.stringify({{before, afterCache, fullLocal}})); +""" + assert json.loads(_run_node(source)) == {"before": True, "afterCache": False, "fullLocal": False} + + +def test_cached_lineage_report_segments_merge_with_materialized_segments_without_duplicates_or_children(): + 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); +}} +const _lineageReportCache = new Map(); +eval(extractFunc('_lineageReportCacheKey')); +eval(extractFunc('_lineageSegmentsForRender')); +_lineageReportCache.set('root', {{ + segments:[ + {{session_id:'tip', title:'Tip', role:'tip', started_at:30}}, + {{session_id:'root', title:'Root', role:'hidden_segment', started_at:20}}, + {{session_id:'older', title:'Older', role:'hidden_segment', started_at:10}}, + {{session_id:'child', title:'Child', role:'child_session', started_at:40}}, + ], + children:[{{session_id:'child', title:'Child', role:'child_session'}}], +}}); +const row = {{ + session_id:'tip', + _lineage_key:'root', + _lineage_segments:[{{session_id:'tip', title:'Tip'}}, {{session_id:'root', title:'Root'}}], +}}; +const segments = _lineageSegmentsForRender(row, 'root').map(seg => seg.session_id); +console.log(JSON.stringify(segments)); +""" + assert json.loads(_run_node(source)) == ["root", "older"] + + +def test_lineage_report_fetch_uses_endpoint_once_and_caches_result(): + 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); +}} +const _lineageReportCache = new Map(); +const _lineageReportInflight = new Map(); +let _lineageReportCacheGeneration = 0; +const calls = []; +function api(path) {{ + calls.push(path); + return Promise.resolve({{found:true, segments:[{{session_id:'tip'}}, {{session_id:'root'}}]}}); +}} +eval(extractFunc('_lineageReportCacheKey')); +eval(extractFunc('_fetchLineageReportForRow')); +(async()=>{{ + const row = {{session_id:'tip', _lineage_key:'root'}}; + const [first, second] = await Promise.all([ + _fetchLineageReportForRow(row, 'root'), + _fetchLineageReportForRow(row, 'root'), + ]); + await _fetchLineageReportForRow(row, 'root'); + console.log(JSON.stringify({{ + calls, + cached:_lineageReportCache.has('root'), + same:first===second, + }})); +}})().catch(err=>{{console.error(err); process.exit(1);}}); +""" + result = json.loads(_run_node(source)) + assert result == { + "calls": ["/api/session/lineage/report?session_id=tip"], + "cached": True, + "same": True, + } + + def test_active_hidden_lineage_segment_auto_expands_parent(): js = SESSIONS_JS_PATH.read_text(encoding="utf-8") source = f"""