mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-23 19:00:14 +00:00
feat: virtualize session sidebar list
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
+126
-5
@@ -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
|
||||
Reference in New Issue
Block a user