feat: virtualize session sidebar list

This commit is contained in:
Michael Lam
2026-05-04 17:03:15 -07:00
committed by test
parent 2bbaad3135
commit 71d0e91c6f
3 changed files with 238 additions and 5 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

+126 -5
View File
@@ -1033,6 +1033,12 @@ let _sessionActionMenu = null;
let _sessionActionAnchor = null;
let _sessionActionSessionId = null;
const _expandedChildSessionKeys = new Set();
let _sessionVisibleSidebarIds = [];
const SESSION_VIRTUAL_ROW_HEIGHT = 52;
const SESSION_VIRTUAL_BUFFER_ROWS = 12;
const SESSION_VIRTUAL_THRESHOLD_ROWS = 80;
let _sessionVirtualScrollList = null;
let _sessionVirtualScrollRaf = 0;
function _sessionIdFromLocation(){
if(typeof window==='undefined'||!window.location) return null;
@@ -1100,9 +1106,13 @@ function setSessionSelected(sid, selected){
}
function selectAllSessions(){
_selectedSessions.clear();
const ids=Array.isArray(_sessionVisibleSidebarIds)&&_sessionVisibleSidebarIds.length
? _sessionVisibleSidebarIds
: Array.from(document.querySelectorAll('.session-select-cb')).map(cb=>cb.dataset.sid).filter(Boolean);
ids.forEach(sid=>_selectedSessions.add(sid));
document.querySelectorAll('.session-select-cb').forEach(cb=>{
const sid=cb.dataset.sid;
if(sid){_selectedSessions.add(sid);cb.checked=true;const item=cb.closest('.session-item');if(item)item.classList.add('selected');}
if(sid){cb.checked=_selectedSessions.has(sid);const item=cb.closest('.session-item');if(item)item.classList.toggle('selected',_selectedSessions.has(sid));}
});
_updateBatchActionBar();
}
@@ -1855,6 +1865,60 @@ function clearOptimisticSessionStreaming(sid){
renderSessionListFromCache();
}
function _sessionVirtualWindow(opts){
const total=Math.max(0, Number(opts&&opts.total)||0);
const threshold=Math.max(1, Number(opts&&opts.threshold)||SESSION_VIRTUAL_THRESHOLD_ROWS);
const itemHeight=Math.max(1, Number(opts&&opts.itemHeight)||SESSION_VIRTUAL_ROW_HEIGHT);
const buffer=Math.max(0, Number(opts&&opts.buffer)||SESSION_VIRTUAL_BUFFER_ROWS);
const viewportHeight=Math.max(itemHeight, Number(opts&&opts.viewportHeight)||itemHeight*10);
const visibleRows=Math.max(1, Math.ceil(viewportHeight/itemHeight));
if(total<=threshold){
return {virtualized:false,start:0,end:total,topPad:0,bottomPad:0,itemHeight,total};
}
let start=Math.floor((Number(opts&&opts.scrollTop)||0)/itemHeight)-buffer;
start=Math.max(0, Math.min(start, Math.max(0,total-visibleRows)));
let end=Math.min(total, start+visibleRows+(buffer*2));
const activeIndex=Number.isFinite(Number(opts&&opts.activeIndex))?Number(opts.activeIndex):-1;
if(activeIndex>=0&&activeIndex<total&&(activeIndex<start||activeIndex>=end)){
start=Math.max(0, Math.min(activeIndex-buffer, Math.max(0,total-visibleRows-(buffer*2))));
end=Math.min(total, start+visibleRows+(buffer*2));
}
return {
virtualized:true,
start,
end,
topPad:start*itemHeight,
bottomPad:Math.max(0,(total-end)*itemHeight),
itemHeight,
total,
};
}
function _sessionVirtualSpacer(height, where){
const spacer=document.createElement('div');
spacer.className='session-virtual-spacer';
spacer.dataset.virtualSpacer=where||'gap';
spacer.setAttribute('aria-hidden','true');
spacer.style.height=Math.max(0,Math.round(height||0))+'px';
spacer.style.flex='0 0 auto';
return spacer;
}
function _scheduleSessionVirtualizedRender(){
if(_renamingSid||_sessionVirtualScrollRaf) return;
_sessionVirtualScrollRaf=requestAnimationFrame(()=>{_sessionVirtualScrollRaf=0;renderSessionListFromCache();});
}
function _ensureSessionVirtualScrollHandler(list){
if(!list||_sessionVirtualScrollList===list) return;
if(_sessionVirtualScrollList){
_sessionVirtualScrollList.removeEventListener('scroll', _scheduleSessionVirtualizedRender);
}
_sessionVirtualScrollList=list;
list.addEventListener('scroll', _scheduleSessionVirtualizedRender, {passive:true});
}
function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
@@ -1897,7 +1961,9 @@ function renderSessionListFromCache(){
const sessions=_attachChildSessionsToSidebarRows(_collapseSessionLineageForSidebar(sessionsRaw), sessionsRaw);
_syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar);
const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');list.innerHTML='';
const list=$('sessionList');
const listScrollTopBeforeRender=list.scrollTop||0;
list.innerHTML='';
// Batch select bar (when in select mode)
if(_sessionSelectMode){
const selectBar=document.createElement('div');selectBar.className='session-select-bar';
@@ -2028,7 +2094,44 @@ function renderSessionListFromCache(){
} else { curItems.push(s); }
}
if(curItems.length) groups.push({label:curLabel,items:curItems});
// Render groups with collapsible headers
const flatSessionRows=[];
for(const g of groups){
if(_groupCollapsed[g.label]) continue;
for(const s of g.items){ flatSessionRows.push({group:g,session:s}); }
}
_sessionVisibleSidebarIds=flatSessionRows.map(row=>row.session&&row.session.session_id).filter(Boolean);
_ensureSessionVirtualScrollHandler(list);
const activeIndex=flatSessionRows.findIndex(row=>_sessionLineageContainsSession(row.session,activeSidForSidebar));
const shouldAnchorActive=activeSidForSidebar&&activeIndex>=0&&(
list.dataset.sessionVirtualActiveAnchor!==activeSidForSidebar||
list.dataset.sessionVirtualFilter!==q
);
let virtualWindow=_sessionVirtualWindow({
total:flatSessionRows.length,
scrollTop:listScrollTopBeforeRender,
viewportHeight:list.clientHeight||520,
itemHeight:SESSION_VIRTUAL_ROW_HEIGHT,
buffer:SESSION_VIRTUAL_BUFFER_ROWS,
threshold:SESSION_VIRTUAL_THRESHOLD_ROWS,
activeIndex:shouldAnchorActive?activeIndex:-1,
});
let virtualAnchorScrollTop=null;
if(shouldAnchorActive&&virtualWindow.virtualized){
list.dataset.sessionVirtualActiveAnchor=activeSidForSidebar;
virtualAnchorScrollTop=virtualWindow.topPad;
}else if(activeSidForSidebar){
list.dataset.sessionVirtualActiveAnchor=activeSidForSidebar;
}else{
delete list.dataset.sessionVirtualActiveAnchor;
}
list.dataset.sessionVirtualTotal=String(flatSessionRows.length);
list.dataset.sessionVirtualFilter=q;
list.dataset.sessionVirtualStart=String(virtualWindow.start);
list.dataset.sessionVirtualEnd=String(virtualWindow.end);
// Render groups with collapsible headers. Large sidebars render only the
// current session-row window plus top/bottom spacers inside each group body;
// headers remain real DOM so pin/archive/date grouping and clicks survive.
let globalSessionRowIndex=0;
for(const g of groups){
const wrapper=document.createElement('div');
wrapper.className='session-date-group';
@@ -2042,19 +2145,37 @@ function renderSessionListFromCache(){
hdr.appendChild(caret);hdr.appendChild(label);
const body=document.createElement('div');
body.className='session-date-body';
if(_groupCollapsed[g.label]){body.style.display='none';caret.classList.add('collapsed');}
const isGroupCollapsed=Boolean(_groupCollapsed[g.label]);
if(isGroupCollapsed){body.style.display='none';caret.classList.add('collapsed');}
hdr.onclick=()=>{
const isCollapsed=body.style.display==='none';
body.style.display=isCollapsed?'':'none';
caret.classList.toggle('collapsed',!isCollapsed);
_groupCollapsed[g.label]=!isCollapsed;
_saveCollapsed();
renderSessionListFromCache();
};
wrapper.appendChild(hdr);
for(const s of g.items){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
let groupTopPad=0;
let groupBottomPad=0;
for(const s of g.items){
if(isGroupCollapsed) continue;
const rowIndex=globalSessionRowIndex++;
const inWindow=!virtualWindow.virtualized||(rowIndex>=virtualWindow.start&&rowIndex<virtualWindow.end);
if(inWindow){ body.appendChild(_renderOneSession(s, Boolean(g.isPinned))); }
else if(rowIndex<virtualWindow.start){ groupTopPad+=virtualWindow.itemHeight; }
else { groupBottomPad+=virtualWindow.itemHeight; }
}
if(groupTopPad>0){ body.insertBefore(_sessionVirtualSpacer(groupTopPad,'before'), body.firstChild); }
if(groupBottomPad>0){ body.appendChild(_sessionVirtualSpacer(groupBottomPad,'after')); }
wrapper.appendChild(body);
list.appendChild(wrapper);
}
if(virtualAnchorScrollTop!==null){
list.scrollTop=virtualAnchorScrollTop;
}else if(virtualWindow.virtualized){
list.scrollTop=listScrollTopBeforeRender;
}
// Select mode toggle button (only when NOT in select mode)
if(!_sessionSelectMode){
const toggleBtn=document.createElement('div');toggleBtn.className='session-select-toggle';
@@ -0,0 +1,112 @@
"""Regression coverage for issue #500 session-sidebar virtualization."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parent.parent.resolve()
SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js"
NODE = shutil.which("node")
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
def _run_node(source: str) -> str:
result = subprocess.run(
[NODE, "-e", source],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise RuntimeError(result.stderr)
return result.stdout.strip()
def _extract_func_script(js: str) -> str:
return 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);
}}
"""
def test_session_virtual_window_reduces_large_lists_and_tracks_scroll():
"""A 1000-row sidebar should render a bounded slice near scroll position."""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
eval(extractFunc('_sessionVirtualWindow'));
const metrics = _sessionVirtualWindow({
total: 1000,
scrollTop: 52 * 420,
viewportHeight: 520,
itemHeight: 52,
buffer: 12,
threshold: 80,
});
console.log(JSON.stringify(metrics));
"""
metrics = json.loads(_run_node(source))
assert metrics["virtualized"] is True
assert 390 <= metrics["start"] <= 420
assert metrics["start"] < metrics["end"] <= 1000
assert metrics["end"] - metrics["start"] <= 40
assert metrics["topPad"] > 0
assert metrics["bottomPad"] > 0
def test_session_virtual_window_keeps_active_session_rendered():
"""The active sidebar row must remain in the DOM when we anchor a new active session."""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + """
eval(extractFunc('_sessionVirtualWindow'));
const metrics = _sessionVirtualWindow({
total: 1000,
scrollTop: 0,
viewportHeight: 520,
itemHeight: 52,
buffer: 12,
threshold: 80,
activeIndex: 995,
});
console.log(JSON.stringify(metrics));
"""
metrics = json.loads(_run_node(source))
assert metrics["virtualized"] is True
assert metrics["start"] <= 995 < metrics["end"]
assert metrics["end"] - metrics["start"] <= 40
def test_session_list_render_path_uses_virtual_spacers_and_scroll_rerender():
"""renderSessionListFromCache should window rows without stale cached slices."""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
render_start = js.index("function renderSessionListFromCache()")
render_end = js.index("async function _handleActiveSessionStorageEvent", render_start)
render_body = js[render_start:render_end]
assert "_sessionVirtualWindow" in render_body
assert "_sessionVirtualSpacer" in render_body
assert "spacer.dataset.virtualSpacer=where||'gap'" in js
assert "list.addEventListener('scroll', _scheduleSessionVirtualizedRender" in js
assert "requestAnimationFrame(()=>{_sessionVirtualScrollRaf=0;renderSessionListFromCache();})" in js
assert "const listScrollTopBeforeRender=list.scrollTop||0" in render_body
assert "scrollTop:listScrollTopBeforeRender" in render_body
assert "list.scrollTop=listScrollTopBeforeRender" in render_body
assert "list.dataset.sessionVirtualFilter!==q" in render_body
assert "list.dataset.sessionVirtualFilter=q" in render_body
assert "const flatSessionRows=[]" in render_body
assert "flatSessionRows.push({group:g,session:s})" in render_body