diff --git a/static/sessions.js b/static/sessions.js index a4b0ba20..c40d4829 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -6328,6 +6328,8 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere }; const orphans=[]; const renderableChildIds=new Set((rawSessions||[]).map(s=>s&&s.session_id).filter(Boolean)); + const childAttachOrderById=new Map(); + let childAttachCursor=0; const attachQueueById=new Map(); for(const candidate of [...(rawSessions||[]),...(referenceSessions||[])]){ if(candidate&&candidate.session_id&&!attachQueueById.has(candidate.session_id)) attachQueueById.set(candidate.session_id,candidate); @@ -6382,6 +6384,9 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere } if(parentRow){ const childCopy={...child}; + if(!childAttachOrderById.has(childCopy.session_id)){ + childAttachOrderById.set(childCopy.session_id, childAttachCursor++); + } if(parentSegment){ childCopy._parent_segment_id=parentSegment.session_id; childCopy._parent_segment_title=_sessionDisplayTitle(parentSegment)||child.parent_title||'Untitled'; @@ -6410,6 +6415,23 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions, rawRefere orphans.push({...child,_orphan_child_session:true}); } } + const resolveReadOnlySession = typeof _isReadOnlySession === 'function' + ? _isReadOnlySession + : ((session) => !!(session && session.read_only)); + for(const row of rows){ + if(Array.isArray(row._child_sessions)&&row._child_sessions.length>1){ + row._child_sessions.sort((a,b)=>{ + const readOnlyCmp = Number(resolveReadOnlySession(a))-Number(resolveReadOnlySession(b)); + if(readOnlyCmp!==0) return readOnlyCmp; + const aOrder = childAttachOrderById.get(a&&a.session_id) || 0; + const bOrder = childAttachOrderById.get(b&&b.session_id) || 0; + return aOrder-bOrder; + }); + } + if(Array.isArray(row._child_sessions)){ + row._child_session_count=row._child_sessions.length; + } + } return [...rows,...orphans]; } @@ -7523,7 +7545,7 @@ function renderSessionListFromCache(){ const childList=document.createElement('div'); childList.className='session-child-sessions'; ['pointerdown','pointerup','click','touchstart','touchmove','touchend','touchcancel'].forEach(ev=>childList.addEventListener(ev,e=>e.stopPropagation())); - const sortedChildren=[...s._child_sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a)); + const sortedChildren=[...s._child_sessions]; const openChildSession=async(childSession)=>{ // #5409: close mobile sidebar synchronously before navigation if(typeof closeMobileSidebar==='function')closeMobileSidebar(); diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 5a7ce5a6..65b262b3 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -554,6 +554,85 @@ console.log(JSON.stringify(rows)); assert rows[0]["_child_sessions"][0]["session_id"] == "subagent_child" +def test_read_only_child_sessions_are_stacked_after_writable_children(): + """Read-only delegated children should render after fork/writable 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); +}} +eval(extractFunc('_isReadOnlySession')); +eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); +eval(extractFunc('_sidebarLineageKeyForRow')); +eval(extractFunc('_attachChildSessionsToSidebarRows')); +const collapsed = [{{ + session_id:'parent', + title:'Parent conversation', + message_count:3, + updated_at: 1700000010, + last_message_at: 1700000010, +}}]; +const raw = [ + collapsed[0], + {{ + session_id:'read_only_subagent', + title:'Retained Subagent', + parent_session_id:'parent', + relationship_type:'child_session', + read_only: true, + raw_source:'subagent', + source_tag:'subagent', + session_source:'other', + source_label:'Subagent', + updated_at: 1700000030, + last_message_at: 1700000030, + }}, + {{ + session_id:'fork_child', + title:'Fork', + parent_session_id:'parent', + session_source:'fork', + raw_source:'webui', + source_tag:'webui', + source_label:'WebUI', + updated_at: 1700000040, + last_message_at: 1700000040, + }}, + {{ + session_id:'writable_subagent', + title:'Writable Subagent', + parent_session_id:'parent', + relationship_type:'child_session', + read_only: false, + raw_source:'subagent', + source_tag:'subagent', + session_source:'other', + source_label:'Subagent', + updated_at: 1700000020, + last_message_at: 1700000020, + }}, +]; +const rows = _attachChildSessionsToSidebarRows(collapsed, raw); +console.log(JSON.stringify(rows[0]._child_sessions.map(row => row.session_id))); +""" + rows = json.loads(_run_node(source)) + assert rows == ["fork_child", "writable_subagent", "read_only_subagent"] + assert "const sortedChildren=[...s._child_sessions];" in js + assert "const sortedChildren=[...s._child_sessions].sort" not in js + + def test_parent_source_label_display_text_does_not_force_external_orphaning(): """Display-only source labels must not drive machine source classification.""" js = SESSIONS_JS_PATH.read_text(encoding="utf-8")