From 5b36232cbfc2af6130c83fc4ad202f24c2e9fdd1 Mon Sep 17 00:00:00 2001 From: Dennis Soong Date: Sat, 9 May 2026 09:49:10 +0800 Subject: [PATCH] feat: expand collapsed session lineage segments --- static/sessions.js | 56 +++++++++++++++++++++++-- static/style.css | 5 +++ tests/test_session_lineage_collapse.py | 58 ++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 8a88217a..383b250a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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;} diff --git a/static/style.css b/static/style.css index d5f4a2f4..9476040c 100644 --- a/static/style.css +++ b/static/style.css @@ -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));} diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 89e4af54..7d1cfdc1 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -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():