feat: expand collapsed session lineage segments

This commit is contained in:
Dennis Soong
2026-05-09 09:49:10 +08:00
parent 0b7e1e60e8
commit 5b36232cbf
3 changed files with 113 additions and 6 deletions
+53 -3
View File
@@ -1097,6 +1097,7 @@ let _sessionActionMenu = null;
let _sessionActionAnchor = null;
let _sessionActionSessionId = null;
const _expandedChildSessionKeys = new Set();
const _expandedLineageKeys = new Set();
let _sessionVisibleSidebarIds = [];
const SESSION_VIRTUAL_ROW_HEIGHT = 52;
const SESSION_VIRTUAL_BUFFER_ROWS = 12;
@@ -1943,6 +1944,9 @@ function _syncSidebarExpansionForActiveSession(rows, activeSid){
if(Array.isArray(row._child_sessions)&&row._child_sessions.some(child=>child&&child.session_id===activeSid)){
_expandedChildSessionKeys.add(key);
}
if(Array.isArray(row._lineage_segments)&&row._lineage_segments.some(seg=>seg&&seg.session_id===activeSid&&seg.session_id!==row.session_id)){
_expandedLineageKeys.add(key);
}
}
}
@@ -2426,13 +2430,34 @@ function renderSessionListFromCache(){
ts.className='session-time'+(hasAttentionState?' is-hidden':'');
ts.textContent=hasAttentionState?'':_formatRelativeSessionTime(tsMs);
titleRow.appendChild(title);
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 lineageSegmentsExpanded=canExpandLineageSegments&&_expandedLineageKeys.has(lineageKey);
if(segmentCount>0){
const segmentCountEl=document.createElement('span');
segmentCountEl.className='session-lineage-count';
segmentCountEl.className='session-lineage-count'+(canExpandLineageSegments?' expandable':'');
const segmentLabel=t('session_meta_segments', segmentCount);
segmentCountEl.textContent=segmentLabel;
segmentCountEl.title=segmentLabel;
if(canExpandLineageSegments){
segmentCountEl.setAttribute('role','button');
segmentCountEl.setAttribute('tabindex','0');
segmentCountEl.setAttribute('aria-expanded',lineageSegmentsExpanded?'true':'false');
['pointerdown','pointerup','click'].forEach(ev=>segmentCountEl.addEventListener(ev,e=>e.stopPropagation()));
const toggleLineageSegments=(e)=>{
e.preventDefault();
e.stopPropagation();
if(_expandedLineageKeys.has(lineageKey)) _expandedLineageKeys.delete(lineageKey);
else _expandedLineageKeys.add(lineageKey);
renderSessionListFromCache();
};
segmentCountEl.onclick=toggleLineageSegments;
segmentCountEl.onkeydown=(e)=>{
if(e.key==='Enter'||e.key===' '){toggleLineageSegments(e);}
};
}
titleRow.appendChild(segmentCountEl);
}
const childCount=typeof s._child_session_count==='number'?s._child_session_count:(Array.isArray(s._child_sessions)?s._child_sessions.length:0);
@@ -2489,7 +2514,32 @@ function renderSessionListFromCache(){
meta.textContent=metaBits.join(' · ');
sessionText.appendChild(meta);
}
const lineageKey=_sidebarLineageKeyForRow(s);
if(lineageSegmentsExpanded){
const lineageList=document.createElement('div');
lineageList.className='session-lineage-segments';
['pointerdown','pointerup','click'].forEach(ev=>lineageList.addEventListener(ev,e=>e.stopPropagation()));
const sortedSegments=[...lineageSegments].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
for(const seg of sortedSegments){
const row=document.createElement('button');
row.type='button';
row.className='session-lineage-segment'+(activeSidForSidebar&&seg.session_id===activeSidForSidebar?' active':'');
const segTitle=seg.title||'Untitled segment';
const segTime=_formatRelativeSessionTime(_sessionTimestampMs(seg));
row.textContent=`-> ${segTitle} - ${segTime}`;
row.title='Open lineage segment';
row.onclick=async(e)=>{
e.stopPropagation();
if(seg.is_cli_session){
try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:seg.session_id})});}
catch(_e){ /* read-only fallback */ }
}
await loadSession(seg.session_id);
renderSessionListFromCache();
};
lineageList.appendChild(row);
}
sessionText.appendChild(lineageList);
}
if(childCount>0&&Array.isArray(s._child_sessions)&&_expandedChildSessionKeys.has(lineageKey)){
const childList=document.createElement('div');
childList.className='session-child-sessions';
@@ -2686,7 +2736,7 @@ function renderSessionListFromCache(){
_pointerActive=false;
if(_renamingSid) return;
if(actions&&actions.contains(e.target)) return;
if(e.target&&e.target.closest&&e.target.closest('.session-child-count,.session-child-sessions,.session-child-session')) return;
if(e.target&&e.target.closest&&e.target.closest('.session-child-count,.session-child-sessions,.session-child-session,.session-lineage-count,.session-lineage-segments,.session-lineage-segment')) return;
if(_sessionSelectMode){e.stopPropagation();if(!readOnly)toggleSessionSelect(s.session_id);return;}
// If the pointer moved enough to be a drag, cancel any pending tap
if(_isDragging){clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0;_clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50);return;}
+5
View File
@@ -2670,7 +2670,12 @@ main.main.showing-logs > #mainLogs{display:flex;}
/* ── Subagent session tree (#494) ── */
.session-lineage-count{display:inline-flex;align-items:center;justify-content:center;height:16px;font-size:10px;font-weight:600;padding:0 6px;border-radius:999px;background:rgba(148,163,184,.14);color:var(--muted);margin-left:6px;flex-shrink:0;user-select:none;cursor:default;}
.session-lineage-count.expandable{cursor:pointer;}
.session-lineage-count.expandable:hover{background:rgba(148,163,184,.24);color:var(--text);}
.session-item.active .session-lineage-count{color:var(--accent-text);background:rgba(255,255,255,.14);}
.session-lineage-segments{display:flex;flex-direction:column;gap:3px;margin-top:6px;margin-left:12px;padding-left:8px;border-left:1px dashed rgba(148,163,184,.22);}
.session-lineage-segment{appearance:none;border:0;background:transparent;color:var(--muted);font:inherit;font-size:11px;text-align:left;padding:3px 4px;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.session-lineage-segment:hover,.session-lineage-segment.active{background:rgba(148,163,184,.12);color:var(--text);}
.session-child-count{display:inline-flex;align-items:center;justify-content:center;height:16px;font-size:10px;font-weight:600;padding:0 6px;border-radius:999px;background:rgba(99,179,237,.16);color:#63b3ed;margin-left:6px;flex-shrink:0;user-select:none;cursor:pointer;}
.session-child-count:hover{background:rgba(99,179,237,.26);color:#90cdf4;}
.session-child-sessions{display:flex;flex-direction:column;gap:3px;margin-top:6px;margin-left:12px;padding-left:8px;border-left:1px solid var(--border,rgba(255,255,255,.1));}
+55 -3
View File
@@ -283,7 +283,7 @@ console.log(JSON.stringify(cases));
assert json.loads(_run_node(source)) == [3, 25, 3, 0, 0]
def test_sidebar_lineage_segment_badge_is_passive_and_localized():
def test_sidebar_lineage_segment_badge_is_localized():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
css = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
assert "session-lineage-count" in js
@@ -291,8 +291,60 @@ def test_sidebar_lineage_segment_badge_is_passive_and_localized():
assert "t('session_meta_segments', segmentCount)" in js
assert "titleRow.appendChild(segmentCountEl);" in js
assert ".session-lineage-count{" in css
assert "cursor:default" in css
assert "session-lineage-count,.session-lineage-segments,.session-lineage-segment" not in js
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 "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 "className='session-lineage-segments'" in js
assert "className='session-lineage-segment'" in js
assert "await loadSession(seg.session_id);" in js
assert ".session-lineage-count.expandable{" in css
assert ".session-lineage-count.expandable:hover" in css
assert ".session-lineage-segments{" in css
assert ".session-lineage-segment{" in css
def test_active_hidden_lineage_segment_auto_expands_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);
}}
const _expandedChildSessionKeys = new Set();
const _expandedLineageKeys = new Set();
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_syncSidebarExpansionForActiveSession'));
const rows = [{{
session_id:'seg10',
_lineage_key:'root',
_lineage_segments:[
{{session_id:'seg10', updated_at:100}},
{{session_id:'seg9', updated_at:90}},
{{session_id:'seg8', updated_at:80}},
],
}}];
_syncSidebarExpansionForActiveSession(rows, 'seg8');
console.log(JSON.stringify({{lineage:[..._expandedLineageKeys], child:[..._expandedChildSessionKeys]}}));
"""
assert json.loads(_run_node(source)) == {"lineage": ["root"], "child": []}
def test_session_meta_segments_locale_key_is_defined_for_sidebar_locales():