Merge pull request #4168 from nesquena/stage-3601b
Some checks failed
Release & Docker / release (push) Has been cancelled

Release NT (v0.51.407): nest forked sessions under their parent (#3224)
This commit is contained in:
nesquena-hermes
2026-06-13 21:42:13 -07:00
committed by GitHub
11 changed files with 896 additions and 166 deletions

View File

@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.407] — 2026-06-14 — Release NT (nest forked sessions under their parent in the sidebar, #3224)
### Added
- **Forked sessions now nest under their parent in the sidebar (#3224).** A session that was forked from another shows a "N children" badge on the parent row; clicking it expands the forks nested directly beneath, indented and collapsible (default collapsed, so the sidebar stays uncluttered when you have no forks). Nested forks keep their full action surface — three-dot menu, rename, swipe archive/delete — and a live state indicator, while read-only/subagent child sessions keep their existing click-to-open behavior. Batch-select reaches expanded forks, and a streaming fork no longer marks its parent as falsely unread when it finishes. (#3224)
## [v0.51.406] — 2026-06-14 — Release NS (project-context file in the Memory tab, #3866)
### Added

View File

@@ -2739,7 +2739,7 @@ function toggleSessionSelect(sid){
else _selectedSessions.add(sid);
_updateBatchActionBar();
const cb=document.querySelector('.session-select-cb[data-sid="'+sid+'"]');
const item=cb?cb.closest('.session-item'):null;
const item=cb?cb.closest('.session-item,.session-child-session-fork'):null;
if(item){item.classList.toggle('selected',_selectedSessions.has(sid));if(cb)cb.checked=_selectedSessions.has(sid);}
}
function setSessionSelected(sid, selected){
@@ -2747,7 +2747,7 @@ function setSessionSelected(sid, selected){
else _selectedSessions.delete(sid);
_updateBatchActionBar();
const cb=document.querySelector('.session-select-cb[data-sid="'+sid+'"]');
const item=cb?cb.closest('.session-item'):null;
const item=cb?cb.closest('.session-item,.session-child-session-fork'):null;
if(item){item.classList.toggle('selected',_selectedSessions.has(sid));if(cb)cb.checked=_selectedSessions.has(sid);}
}
function selectAllSessions(){
@@ -2758,13 +2758,13 @@ function selectAllSessions(){
ids.forEach(sid=>_selectedSessions.add(sid));
document.querySelectorAll('.session-select-cb').forEach(cb=>{
const sid=cb.dataset.sid;
if(sid){cb.checked=_selectedSessions.has(sid);const item=cb.closest('.session-item');if(item)item.classList.toggle('selected',_selectedSessions.has(sid));}
if(sid){cb.checked=_selectedSessions.has(sid);const item=cb.closest('.session-item,.session-child-session-fork');if(item)item.classList.toggle('selected',_selectedSessions.has(sid));}
});
_updateBatchActionBar();
}
function deselectAllSessions(){
_selectedSessions.clear();
document.querySelectorAll('.session-select-cb').forEach(cb=>{cb.checked=false;const item=cb.closest('.session-item');if(item)item.classList.remove('selected');});
document.querySelectorAll('.session-select-cb').forEach(cb=>{cb.checked=false;const item=cb.closest('.session-item,.session-child-session-fork');if(item)item.classList.remove('selected');});
_updateBatchActionBar();
}
function _updateBatchActionBar(){
@@ -2871,7 +2871,7 @@ function closeSessionActionMenu(){
if(_sessionActionAnchor.classList&&_sessionActionAnchor.classList.contains('session-actions-trigger')){
_sessionActionAnchor.classList.remove('active');
}
const row=_sessionActionAnchor.closest('.session-item');
const row=_sessionActionAnchor.closest('.session-item,.session-child-session');
if(row) row.classList.remove('menu-open','long-pressing');
_sessionActionAnchor = null;
}
@@ -2990,12 +2990,89 @@ function _mountSessionActionMenu(menu, session, anchorEl){
_sessionActionAnchor = anchorEl;
_sessionActionSessionId = session.session_id;
if(anchorEl.classList&&anchorEl.classList.contains('session-actions-trigger')) anchorEl.classList.add('active');
const row=anchorEl.closest('.session-item');
const row=anchorEl.closest('.session-item,.session-child-session');
if(row) row.classList.add('menu-open');
_positionSessionActionMenu(anchorEl);
_playSessionActionMenuEntrance(menu);
}
function _findSessionRenameRow(sessionId){
const sid=String(sessionId||'');
if(!sid) return null;
return document.querySelector('.session-item[data-sid="'+sid+'"], .session-child-session[data-sid="'+sid+'"]');
}
function _buildSessionRenameStarter(session, displayEl, renderDisplay){
return ()=>{
if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be renamed.',3000); return; }
if(_loadingSessionId&&_loadingSessionId!==session.session_id) return;
closeSessionActionMenu();
_renamingSid=session.session_id;
const oldTitle=_sessionDisplayTitle(session)||'Untitled';
const inp=document.createElement('input');
inp.className='session-title-input';
inp.value=oldTitle;
['click','mousedown','dblclick','pointerdown'].forEach(ev=>
inp.addEventListener(ev, e2=>e2.stopPropagation())
);
const applyLocalTitle=(target, nextTitle)=>{
if(!target) return;
target.title=nextTitle;
target.display_title=nextTitle;
target._state_db_title=nextTitle;
};
const applyTitle=(nextTitle, updateDom=true)=>{
applyLocalTitle(session, nextTitle);
const cached=_allSessions.find(item=>item&&item.session_id===session.session_id);
applyLocalTitle(cached, nextTitle);
if(S.session&&S.session.session_id===session.session_id){applyLocalTitle(S.session, nextTitle);syncTopbar();}
if(updateDom) renderDisplay(_sessionDisplayTitle(session), session);
};
let finishDone=false;
const finish=async(save)=>{
if(finishDone) return;
finishDone=true;
const releaseRename=()=>{
_renamingSid=null;
if(inp.isConnected) inp.replaceWith(displayEl);
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
};
if(!save){
applyTitle(oldTitle,false);
releaseRename();
return;
}
const newTitle=inp.value.trim()||'Untitled';
try{
if(newTitle!==oldTitle){
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:session.session_id,title:newTitle})});
}
applyTitle(newTitle);
}catch(err){
applyTitle(oldTitle,false);
const msg='Rename failed: '+(err&&err.message?err.message:String(err));
setStatus(msg);
if(typeof showToast==='function') showToast(msg,3000,'error');
}finally{
releaseRename();
}
};
inp.onkeydown=e2=>{
if(e2.key==='Enter'){
if(window._isImeEnter&&window._isImeEnter(e2)){return;}
e2.preventDefault();
e2.stopPropagation();
finish(true);
}
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
};
inp.onblur=()=>{ if(_renamingSid===session.session_id) finish(true); };
displayEl.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
};
}
function _appendSessionCopyLinkAction(menu, session){
menu.appendChild(_buildSessionAction(
t('session_copy_link'),
@@ -3102,7 +3179,7 @@ function _openSessionActionMenu(session, anchorEl){
// Falls back to a no-op toast if the row isn't currently rendered
// (e.g. archived-and-hidden) — extremely rare since the menu only
// opens from a visible row's three-dot button.
const row=document.querySelector('.session-item[data-sid="'+session.session_id+'"]');
const row=_findSessionRenameRow(session.session_id);
if(row && typeof row._startRename === 'function'){
row._startRename();
} else if(typeof showToast==='function'){
@@ -4251,6 +4328,10 @@ function _isChildSession(s){
return !!(s&&s.parent_session_id&&s.relationship_type==='child_session');
}
function _isForkWithResolvableParent(s, sessionIdsInList){
return !!(s&&s.session_source==='fork'&&s.parent_session_id&&sessionIdsInList&&sessionIdsInList.has(s.parent_session_id));
}
function _sessionLineageKey(s, sessionIdsInList, sessionsById){
if(!s||!s.session_id) return null;
if(_isChildSession(s)) return null;
@@ -4298,7 +4379,7 @@ function _resolveSessionIdFromSidebarLineage(sid){
const candidates=[];
for(const row of visibleRows){
if(!row||!row.session_id) continue;
if(row.session_source==='fork'||row.relationship_type==='child_session') continue;
if(row.relationship_type==='child_session') continue;
const lineageLike=!!(
row._lineage_key||row._lineage_root_id||row.lineage_root_id||
row._compression_segment_count||row.pre_compression_snapshot||
@@ -4433,6 +4514,7 @@ function _fetchLineageReportForRow(s,lineageKey){
function _sidebarLineageKeyForRow(s){
if(!s) return null;
if(s.session_source==='fork') return s.session_id||s.parent_session_id||null;
return s._lineage_key||s._lineage_root_id||s.lineage_root_id||s.parent_session_id||s.session_id||null;
}
@@ -4491,10 +4573,49 @@ function _sessionStateTooltip({isStreaming=false,hasUnread=false}={}){
}
function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions){
const rows=(collapsedRows||[]).filter(s=>!_isChildSession(s)).map(s=>({...s}));
const sessionIdsInList=new Set((rawSessions||[]).map(s=>s&&s.session_id).filter(Boolean));
const rawSessionsById=new Map((rawSessions||[]).filter(s=>s&&s.session_id).map(s=>[s.session_id,s]));
const rows=(collapsedRows||[])
.filter(s=>!_isChildSession(s)&&((s&&s.pinned)||!_isForkWithResolvableParent(s, sessionIdsInList)))
.map(s=>({...s}));
const isChildStreaming=(childRow)=>typeof _isSessionEffectivelyStreaming==='function'
? _isSessionEffectivelyStreaming(childRow)
: !!(childRow&&(childRow.active_stream_id||childRow.pending_user_message));
const childHasUnread=(childRow)=>typeof _hasUnreadForSession==='function'
? _hasUnreadForSession(childRow)
: !!(childRow&&childRow.has_unread);
const bubbleSidebarState=(parentRow, childRow)=>{
if(isChildStreaming(childRow)) parentRow._child_session_streaming=true;
if(childHasUnread(childRow)) parentRow._child_session_has_unread=true;
const childAttention=childRow&&childRow.attention&&typeof childRow.attention==='object'?childRow.attention:null;
if(!childAttention||!childAttention.kind||!Number.isFinite(Number(childAttention.count))||Number(childAttention.count)<=0) return;
const priorityFor=(kind)=>kind==='approval'?3:(kind==='clarify'?2:1);
const current=parentRow._child_session_attention&&typeof parentRow._child_session_attention==='object'
? parentRow._child_session_attention
: null;
const nextPriority=priorityFor(String(childAttention.kind));
const currentPriority=current?priorityFor(String(current.kind)):0;
if(!current||nextPriority>currentPriority||(nextPriority===currentPriority&&Number(childAttention.count||0)>Number(current.count||0))){
parentRow._child_session_attention={...childAttention};
}
};
const visibleBySid=new Map();
const visibleBySegmentSid=new Map();
const visibleByLineageKey=new Map();
const attachDepthCache=new Map();
const attachDepthFor=(session, seen=new Set())=>{
if(!session||!session.session_id) return 0;
if(attachDepthCache.has(session.session_id)) return attachDepthCache.get(session.session_id);
if(seen.has(session.session_id)) return 0;
seen.add(session.session_id);
const parent=session.parent_session_id&&rawSessionsById.get(session.parent_session_id);
let depth=0;
if(parent&&(_isChildSession(session)||(_isForkWithResolvableParent(session, sessionIdsInList)&&!(session&&session.pinned)))){
depth=1+attachDepthFor(parent, seen);
}
attachDepthCache.set(session.session_id, depth);
return depth;
};
for(const row of rows){
if(row&&row.session_id) visibleBySid.set(row.session_id,row);
const lineageKey=_sidebarLineageKeyForRow(row);
@@ -4504,9 +4625,11 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions){
}
}
const orphans=[];
for(const child of rawSessions||[]){
if(!_isChildSession(child)) continue;
if(child._cross_surface_child_session){
const attachQueue=[...(rawSessions||[])].sort((a,b)=>attachDepthFor(a)-attachDepthFor(b));
for(const child of attachQueue){
const isForkChild=_isForkWithResolvableParent(child, sessionIdsInList)&&!(child&&child.pinned);
if(!_isChildSession(child)&&!isForkChild) continue;
if(!isForkChild&&child._cross_surface_child_session){
orphans.push({...child,_orphan_child_session:true});
continue;
}
@@ -4530,6 +4653,8 @@ function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions){
}
parentRow._child_sessions.push(childCopy);
parentRow._child_session_count=parentRow._child_sessions.length;
bubbleSidebarState(parentRow, childCopy);
visibleBySegmentSid.set(childCopy.session_id,{row: parentRow, seg: childCopy});
} else {
orphans.push({...child,_orphan_child_session:true});
}
@@ -5149,6 +5274,17 @@ function renderSessionListFromCache(){
for(const s of g.items){ flatSessionRows.push({group:g,session:s}); }
}
_sessionVisibleSidebarIds=flatSessionRows.map(row=>row.session&&row.session.session_id).filter(Boolean);
for(const row of flatSessionRows){
const s=row.session;
if(!s||!Array.isArray(s._child_sessions)) continue;
const key=_sidebarLineageKeyForRow(s);
if(!_expandedChildSessionKeys.has(key)&&!searchQueryRaw) continue;
for(const child of s._child_sessions){
if(child&&child.session_source==='fork'&&child.session_id&&!_isReadOnlySession(child)){
_sessionVisibleSidebarIds.push(child.session_id);
}
}
}
_ensureSessionVirtualScrollHandler(list);
const activeIndex=flatSessionRows.findIndex(row=>_sessionLineageContainsSession(row.session,activeSidForSidebar));
const shouldAnchorActive=activeSidForSidebar&&activeIndex>=0&&(
@@ -5260,11 +5396,12 @@ function renderSessionListFromCache(){
function _renderOneSession(s, isPinnedGroup=false){
const el=document.createElement('div');
const isActive=_sessionLineageContainsSession(s,activeSidForSidebar);
const isStreaming=_isSessionEffectivelyStreaming(s);
_rememberRenderedStreamingState(s, isStreaming);
const ownStreaming=_isSessionEffectivelyStreaming(s);
const isStreaming=ownStreaming||!!s._child_session_streaming;
_rememberRenderedStreamingState(s, ownStreaming);
_rememberRenderedSessionSnapshot(s);
const hasUnread=_hasUnreadForSession(s)&&!isActive;
const attention=_sessionAttentionState(s);
const hasUnread=(_hasUnreadForSession(s)||!!s._child_session_has_unread)&&!isActive;
const attention=_sessionAttentionState(s)||_sessionAttentionState({_child:true,attention:s._child_session_attention});
const attentionClass=attention?(attention.kind==='approval'?' attention-approval':(attention.kind==='clarify'?' attention-clarify':' attention-attention')):'';
const readOnly=_isReadOnlySession(s);
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'')+(hasUnread?' unread':'')+(attention?' needs-attention':'')+attentionClass;
@@ -5484,28 +5621,312 @@ function renderSessionListFromCache(){
}
sessionText.appendChild(lineageList);
}
if(childCount>0&&Array.isArray(s._child_sessions)&&_expandedChildSessionKeys.has(lineageKey)){
if(childCount>0&&Array.isArray(s._child_sessions)&&(_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw)){
const childList=document.createElement('div');
childList.className='session-child-sessions';
['pointerdown','pointerup','click'].forEach(ev=>childList.addEventListener(ev,e=>e.stopPropagation()));
['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));
for(const child of sortedChildren){
const row=document.createElement('button');
row.type='button';
row.className='session-child-session'+(activeSidForSidebar&&child.session_id===activeSidForSidebar?' active':'');
const openChildSession=async(childSession)=>{
if(_isExternalSession(childSession)){
try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:childSession.session_id})});}
catch(_e){ /* read-only fallback */ }
}
await loadSession(childSession.session_id, {skipLineageResolve:true});
renderSessionListFromCache();
};
const childLabelFor=(child)=>{
const childTitle=_sessionDisplayTitle(child)||'Untitled child session';
const childTime=_formatRelativeSessionTime(_sessionTimestampMs(child));
const parentNote=child._parent_segment_title?` via ${child._parent_segment_title}`:'';
row.textContent=`-> ${childTitle}${parentNote} - ${childTime}`;
return `-> ${childTitle}${parentNote} - ${childTime}`;
};
const installForkChildSwipe=(rowEl, childSession, actionsEl)=>{
let _pointerDownX=0;
let _pointerDownY=0;
let _pointerX=0;
let _pointerY=0;
let _gestureState='idle';
let _swipeTracking=false;
let _gesturePointerType='';
let _clearDragTimer=null;
let _longPressTimer=null;
let _longPressMenuOpened=false;
const _isForkSwipeTarget=()=>_gesturePointerType!=='mouse'&&!_sessionSelectMode;
const _isForkActionTarget=(target)=>!!(actionsEl&&target&&actionsEl.contains(target));
const _clearForkLongPressTimer=()=>{
if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;}
if(!_longPressMenuOpened) rowEl.classList.remove('long-pressing');
};
const _beginForkGesture=(clientX,clientY,pointerType='')=>{
_gesturePointerType=pointerType;
_pointerDownX=clientX;
_pointerDownY=clientY;
_pointerX=clientX;
_pointerY=clientY;
_gestureState='pressing';
_swipeTracking=false;
_longPressMenuOpened=false;
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
rowEl.classList.remove('dragging','swipe-committed','swipe-removing');
rowEl.style.removeProperty('height');
rowEl.style.removeProperty('min-height');
};
const _scheduleForkLongPressMenu=()=>{
_clearForkLongPressTimer();
rowEl.classList.add('long-pressing');
_longPressTimer=setTimeout(()=>{
if(_gestureState!=='pressing'||_renamingSid||_sessionSelectMode) return;
_longPressMenuOpened=true;
rowEl._skipNextChildOpen=true;
_openSessionActionMenu(childSession, rowEl);
},SESSION_LONG_PRESS_DELAY_MS);
};
const _paintForkSwipe=(signedDx)=>{
const rawOffset=signedDx*.55;
const revealedOffset=Math.max(-72,Math.min(72,rawOffset));
const overshoot=Math.max(0,Math.abs(rawOffset)-72);
const offset=Math.sign(rawOffset)*(Math.abs(revealedOffset)+Math.sqrt(overshoot)*5);
const progress=Math.min(1,Math.abs(revealedOffset)/72);
const reveal=Math.abs(offset);
const actionRevealScale=1.15;
const iconScale=Math.min(1,Math.max(.01,progress*actionRevealScale));
const badgeSize=34*iconScale;
const iconSize=18*iconScale;
const labelScale=Math.min(1,Math.max(.01,progress*actionRevealScale));
const actionOpacity=Math.min(1,Math.max(.01,progress*actionRevealScale));
const actionInset=6;
const tileGap=6;
const stretchStart=72/actionRevealScale;
const stretchProgress=Math.max(0,reveal-stretchStart);
const badgeStretch=Math.min(Math.max(0,reveal-34),stretchProgress*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap));
rowEl.style.setProperty('--session-swipe-offset',offset+'px');
rowEl.style.setProperty('--session-swipe-reveal',reveal+'px');
rowEl.style.setProperty('--session-swipe-badge-size',badgeSize+'px');
rowEl.style.setProperty('--session-swipe-icon-size',iconSize+'px');
rowEl.style.setProperty('--session-swipe-label-scale',labelScale);
rowEl.style.setProperty('--session-swipe-badge-stretch',badgeStretch+'px');
rowEl.style.setProperty('--session-swipe-progress',actionOpacity);
rowEl.classList.toggle('swiping-right',offset>0);
rowEl.classList.toggle('swiping-left',offset<0);
};
const _clearForkSwipePaint=()=>{
rowEl.style.removeProperty('--session-swipe-offset');
rowEl.style.removeProperty('--session-swipe-reveal');
rowEl.style.removeProperty('--session-swipe-badge-size');
rowEl.style.removeProperty('--session-swipe-icon-size');
rowEl.style.removeProperty('--session-swipe-label-scale');
rowEl.style.removeProperty('--session-swipe-badge-stretch');
rowEl.style.removeProperty('--session-swipe-progress');
rowEl.style.removeProperty('height');
rowEl.style.removeProperty('min-height');
rowEl.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing');
};
const _settleForkSwipePaint=()=>{
rowEl.classList.remove('dragging');
requestAnimationFrame(()=>requestAnimationFrame(_clearForkSwipePaint));
};
const _completeForkSwipePaint=(signedDx)=>{
rowEl.classList.remove('dragging');
rowEl.classList.add('swipe-committed');
rowEl.style.setProperty('--session-swipe-progress','0');
rowEl.style.setProperty('--session-swipe-offset',(signedDx>0?1:-1)*window.innerWidth+'px');
const rect=rowEl.getBoundingClientRect();
rowEl.style.height=rect.height+'px';
rowEl.style.minHeight=rect.height+'px';
requestAnimationFrame(()=>rowEl.classList.add('swipe-removing'));
};
const _canSwipeDeleteFork=()=>_isForkSwipeTarget()&&!_isMessagingSession(childSession)&&!_isCliSession(childSession);
const _handleForkSwipe=(signedDx,signedDy)=>{
if(_gestureState==='committed'||!_isForkSwipeTarget()) return false;
const actionThreshold=signedDx>0?SESSION_ARCHIVE_SWIPE_THRESHOLD_PX:SESSION_DELETE_SWIPE_THRESHOLD_PX;
if(Math.abs(signedDx)<actionThreshold) return false;
if(Math.abs(signedDy)>Math.abs(signedDx)*SESSION_SWIPE_CANCEL_RATIO) return false;
_gestureState='committed';
closeSessionActionMenu();
if(signedDx>0){
if(childSession.archived){
_settleForkSwipePaint();
_archiveSession(childSession,false,()=>_waitForSessionMotion(committedSwipeDuration)).then((restored)=>{
if(!restored) _settleForkSwipePaint();
});
}else if(_showArchived){
_settleForkSwipePaint();
_archiveSession(childSession,true,()=>_waitForSessionMotion(committedSwipeDuration)).then((archived)=>{
if(!archived) _settleForkSwipePaint();
});
}else{
_completeForkSwipePaint(signedDx);
_archiveSession(childSession,true,()=>_waitForSessionMotion(committedSwipeReflowDelay)).then((archived)=>{
if(!archived) _settleForkSwipePaint();
});
}
}else if(_canSwipeDeleteFork()){
rowEl.classList.remove('dragging');
deleteSession(childSession.session_id,async()=>{
_completeForkSwipePaint(signedDx);
await _waitForSessionMotion(committedSwipeReflowDelay);
}).then((deleted)=>{
if(!deleted) _settleForkSwipePaint();
});
}else if(typeof showToast==='function'){
showToast('Imported sessions cannot be deleted here.',3000);
_gestureState='dragging';
_settleForkSwipePaint();
}
return true;
};
const _clearForkPointerState=()=>{
_clearForkLongPressTimer();
const wasDragging=_gestureState==='dragging'||_swipeTracking;
_gestureState='idle';
if(wasDragging){
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
_clearDragTimer=setTimeout(()=>{_settleForkSwipePaint();_clearDragTimer=null;},50);
}
};
rowEl.onpointerdown=(e)=>{
if(e.pointerType==='mouse'||e.button!==0||_isForkActionTarget(e.target)) return;
_beginForkGesture(e.clientX,e.clientY,e.pointerType||'');
if(e.pointerType==='touch'||e.pointerType==='pen') _scheduleForkLongPressMenu();
};
rowEl.onpointermove=(e)=>{
if(e.pointerType==='mouse'||_gestureState==='idle') return;
_pointerX=e.clientX;
_pointerY=e.clientY;
const signedDx=e.clientX-_pointerDownX;
const signedDy=e.clientY-_pointerDownY;
const dx=Math.abs(signedDx);
const dy=Math.abs(signedDy);
if(dx>8&&dx>dy*1.1) _swipeTracking=true;
if(_gestureState==='pressing'&&(dx>5||dy>5)){
_clearForkLongPressTimer();
_gestureState='dragging';
rowEl.classList.add('dragging');
}
if(_isForkSwipeTarget()&&(_swipeTracking||dx>dy)) _paintForkSwipe(signedDx);
};
rowEl.onpointerup=(e)=>{
if(e.pointerType==='mouse'||e.button!==0) return;
if(_gestureState==='idle') return;
if(_longPressMenuOpened){_gestureState='idle';return;}
if(_isForkActionTarget(e.target)){_gestureState='idle';return;}
_pointerX=e.clientX;
_pointerY=e.clientY;
if(_handleForkSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY)){
e.preventDefault();
e.stopPropagation();
return;
}
_clearForkPointerState();
};
rowEl.onpointercancel=()=>_clearForkPointerState();
rowEl.onpointerleave=()=>{
if(_gesturePointerType!=='mouse'&&_gestureState!=='idle') _clearForkPointerState();
};
};
for(const child of sortedChildren){
if(child.session_source==='fork'){
const childIsActive=!!(activeSidForSidebar&&child.session_id===activeSidForSidebar);
const childStreaming=_isSessionEffectivelyStreaming(child);
const childHasUnread=_hasUnreadForSession(child)&&!childIsActive;
const childAttention=_sessionAttentionState(child);
const childAttentionClass=childAttention?(childAttention.kind==='approval'?' attention-approval':(childAttention.kind==='clarify'?' attention-clarify':' attention-attention')):'';
const row=document.createElement('div');
row.className='session-child-session session-child-session-fork'
+(childIsActive?' active':'')
+(childStreaming?' streaming':'')
+(childHasUnread?' unread':'')
+(childAttention?' needs-attention':'')
+childAttentionClass;
row.dataset.sid=child.session_id;
if(_sessionSelectMode&&!_isReadOnlySession(child)){
const cbW=document.createElement('label');cbW.className='session-select-cb-wrapper';
const cb=document.createElement('input');cb.type='checkbox';cb.className='session-select-cb';
cb.dataset.sid=child.session_id;cb.checked=_selectedSessions.has(child.session_id);
cb.onchange=(e)=>{e.stopPropagation();setSessionSelected(child.session_id,cb.checked);};
cb.onclick=(e)=>{e.stopPropagation();};
cb.onpointerup=(e)=>{e.stopPropagation();};
cbW.onpointerup=(e)=>{e.stopPropagation();};
cbW.onclick=(e)=>{e.stopPropagation();};
cbW.appendChild(cb);
row.classList.toggle('selected',_selectedSessions.has(child.session_id));
row.appendChild(cbW);
}
const mainBtn=document.createElement('button');
mainBtn.type='button';
mainBtn.className='session-child-session-main'+(childIsActive?' active':'');
mainBtn.textContent=childLabelFor(child);
mainBtn.title='Open forked session';
mainBtn.onclick=async(e)=>{
if(row._skipNextChildOpen){
row._skipNextChildOpen=false;
e.stopPropagation();
e.preventDefault();
return;
}
e.stopPropagation();
await openChildSession(child);
};
row._startRename=_buildSessionRenameStarter(child, mainBtn, ()=>{
mainBtn.textContent=childLabelFor(child);
});
row.appendChild(mainBtn);
const state=document.createElement('span');
state.className='session-state-indicator session-child-session-state'
+(childStreaming?' is-streaming':'')
+(childHasUnread?' is-unread':'')
+(childAttention?(childAttention.kind==='approval'?' is-attention-approval':(childAttention.kind==='clarify'?' is-attention-clarify':' is-attention-generic')):'');
state.setAttribute('aria-hidden','true');
const childStateTip=_sessionStateTooltip({isStreaming:childStreaming,hasUnread:childHasUnread});
if(childAttention&&childAttention.title) state.title=childAttention.title;
else if(childStateTip) state.title=childStateTip;
row.appendChild(state);
const readOnlyChild=_isReadOnlySession(child);
let actions=null;
if(!readOnlyChild){
actions=document.createElement('div');
actions.className='session-actions';
const menuBtn=document.createElement('button');
menuBtn.type='button';
menuBtn.className='session-actions-trigger';
menuBtn.title='Conversation actions';
menuBtn.setAttribute('aria-haspopup','menu');
menuBtn.setAttribute('aria-label','Conversation actions');
menuBtn.innerHTML=ICONS.more;
const stopMenuPointer=(e)=>e.stopPropagation();
menuBtn.onpointerdown=stopMenuPointer;
menuBtn.onpointerup=stopMenuPointer;
menuBtn.onclick=(e)=>{
e.stopPropagation();
e.preventDefault();
_openSessionActionMenu(child, menuBtn);
};
actions.appendChild(menuBtn);
row.appendChild(actions);
row.append(
_makeSessionSwipeAffordance('right',child.archived?'undo':'archive',child.archived?'Restore':t('session_batch_archive')),
_makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')),
);
installForkChildSwipe(row, child, actions);
}
row.oncontextmenu=(e)=>{
if(readOnlyChild) return;
e.preventDefault();
if(e.pointerType==='touch'||e.pointerType==='pen') return;
e.stopPropagation();
_openSessionActionMenu(child, actions||row);
};
childList.appendChild(row);
continue;
}
const row=document.createElement('button');
row.type='button';
row.className='session-child-session'+(activeSidForSidebar&&child.session_id===activeSidForSidebar?' active':'');
row.textContent=childLabelFor(child);
row.title='Open child session';
row.onclick=async(e)=>{
e.stopPropagation();
if(_isExternalSession(child)){
try{await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:child.session_id})});}
catch(_e){ /* read-only fallback */ }
}
await loadSession(child.session_id, {skipLineageResolve:true});
renderSessionListFromCache();
await openChildSession(child);
};
childList.appendChild(row);
}
@@ -5526,74 +5947,14 @@ function renderSessionListFromCache(){
}
// Rename: called directly when we confirm it's a double-click
const startRename=()=>{
if(_isReadOnlySession(s)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be renamed.',3000); return; }
// Guard: prevent renaming if session is currently being loaded
if (_loadingSessionId && _loadingSessionId !== s.session_id) return;
closeSessionActionMenu();
_renamingSid = s.session_id;
const oldTitle=s.title||'Untitled';
const inp=document.createElement('input');
inp.className='session-title-input';
inp.value=oldTitle;
['click','mousedown','dblclick','pointerdown'].forEach(ev=>
inp.addEventListener(ev, e2=>e2.stopPropagation())
);
const applyTitle=(nextTitle, updateDom=true)=>{
if(updateDom) title.textContent=nextTitle;
s.title=nextTitle;
const cached=_allSessions.find(item=>item&&item.session_id===s.session_id);
if(cached) cached.title=nextTitle;
if(S.session&&S.session.session_id===s.session_id){S.session.title=nextTitle;syncTopbar();}
};
let finishDone=false;
const finish=async(save)=>{
if(finishDone) return;
finishDone=true;
const releaseRename=()=>{
_renamingSid = null;
if(inp.isConnected) inp.replaceWith(title);
// Allow list re-renders again after DOM cleanup has completed.
setTimeout(()=>{ if(_renamingSid===null) renderSessionListFromCache(); },50);
};
if(!save){
applyTitle(oldTitle,false);
releaseRename();
return;
}
const newTitle=inp.value.trim()||'Untitled';
try{
if(newTitle!==oldTitle){
await api('/api/session/rename',{method:'POST',body:JSON.stringify({session_id:s.session_id,title:newTitle})});
}
applyTitle(newTitle);
}catch(err){
applyTitle(oldTitle,false);
const msg='Rename failed: '+(err&&err.message?err.message:String(err));
setStatus(msg);
if(typeof showToast==='function') showToast(msg,3000,'error');
}finally{
releaseRename();
}
};
inp.onkeydown=e2=>{
if(e2.key==='Enter'){
if(window._isImeEnter&&window._isImeEnter(e2)){return;}
e2.preventDefault();
e2.stopPropagation();
finish(true);
}
if(e2.key==='Escape'){e2.preventDefault();e2.stopPropagation();finish(false);}
};
// onblur: save on blur — Escape explicitly cancels. The old cancel-on-blur
// behavior broke rename on mobile (iPhone "Done" dismisses the keyboard,
// triggering blur) and was less natural on desktop too (typing a name then
// clicking elsewhere should save, not discard).
inp.onblur=()=>{ if(_renamingSid===s.session_id) finish(true); };
title.replaceWith(inp);
setTimeout(()=>{inp.focus();inp.select();},10);
};
const startRename=_buildSessionRenameStarter(
s,
title,
(nextTitle)=>{
title.textContent=nextTitle;
title.title=_sessionFullTitleTooltip(nextTitle,nextTitle,s);
}
);
// Expose the rename closure on the row so the three-dot action menu
// (`_openSessionActionMenu`, defined elsewhere) can trigger it without
// needing a separate DOM hunt or a duplicate copy of all this state

View File

@@ -1022,7 +1022,19 @@
.session-item.attention-approval{box-shadow:inset 3px 0 0 var(--error);background:color-mix(in srgb,var(--error) 9%,transparent);}
.session-item.attention-clarify{box-shadow:inset 3px 0 0 var(--warning);background:color-mix(in srgb,var(--warning) 8%,transparent);}
@media (hover:hover){.session-item:hover{background:var(--hover-bg);color:var(--text);}}
.session-item.long-pressing{background:var(--hover-bg);color:var(--text);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 38%,transparent);animation:session-long-press .4s cubic-bezier(.2,.8,.2,1) both;}
.session-item.loading{background:var(--hover-bg);color:var(--text);}
.session-item.long-pressing{
background:var(--hover-bg);
color:var(--text);
box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 38%,transparent);
animation:session-long-press .4s cubic-bezier(.2,.8,.2,1) both;
}
.session-child-session-fork.long-pressing{
background:var(--hover-bg);
color:var(--text);
box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 38%,transparent);
animation:session-long-press .4s cubic-bezier(.2,.8,.2,1) both;
}
.session-item.swiping-right{background:color-mix(in srgb,var(--warning) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 48%,transparent);}
.session-item.swiping-left{background:color-mix(in srgb,var(--error) 14%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 48%,transparent);}
.session-swipe-affordance{position:absolute;top:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;width:var(--session-swipe-reveal,0px);padding:0;box-sizing:border-box;opacity:0;overflow:hidden;transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0);pointer-events:none;z-index:0;}
@@ -4507,6 +4519,22 @@ main.main > #mainPlugin{display:none;}
.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));}
.session-child-session{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-child-session:hover,.session-child-session.active{background:rgba(255,255,255,.06);color:var(--text);}
.session-child-session-fork{position:relative;display:flex;align-items:center;gap:4px;padding:2px 4px 2px 0;overflow:visible;white-space:normal;touch-action:pan-y;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;transform:translate3d(var(--session-swipe-offset,0),0,0);transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease;}
.session-child-session-fork .session-child-session-main{appearance:none;border:0;background:transparent;color:inherit;font:inherit;font-size:11px;text-align:left;padding:3px 4px;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;min-width:0;}
.session-child-session-fork:hover .session-child-session-main,.session-child-session-fork.active .session-child-session-main,.session-child-session-fork .session-child-session-main.active{background:rgba(255,255,255,.06);color:var(--text);}
.session-child-session-fork.streaming .session-child-session-main{color:var(--accent);}
.session-child-session-fork .session-actions{position:static;transform:none;opacity:1;pointer-events:auto;flex:0 0 auto;}
.session-child-session-fork .session-child-session-state{position:static;transform:none;flex:0 0 auto;width:14px;height:14px;margin-right:2px;color:var(--accent);}
.session-child-session-fork .session-actions-trigger{width:22px;height:22px;}
.session-child-session-fork .session-swipe-affordance{top:1px;bottom:1px;}
.session-child-session-fork .session-child-session-main,.session-child-session-fork .session-actions{z-index:1;}
.session-child-session-fork.swiping-right{background:color-mix(in srgb,var(--warning) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 48%,transparent);}
.session-child-session-fork.swiping-left{background:color-mix(in srgb,var(--error) 14%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 48%,transparent);}
.session-child-session-fork.swiping-right .session-swipe-affordance-right,.session-child-session-fork.swiping-left .session-swipe-affordance-left{opacity:var(--session-swipe-progress,0);}
.session-child-session-fork.dragging{transition:background .15s,color .15s,box-shadow .15s ease;will-change:transform;}
.session-child-session-fork.swipe-committed,.session-child-session-fork.swipe-removing{transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease;will-change:transform;}
.session-child-session-fork.swipe-removing{overflow:hidden;}
.session-child-session-fork.swipe-committed .session-swipe-affordance{transition:opacity .18s ease,transform .18s ease;}
.session-tree-children{margin-left:16px;border-left:1px solid var(--border,rgba(255,255,255,.1));padding-left:4px;}
.session-tree-child.session-item{font-size:12px;opacity:.85;border-radius:6px;padding:6px 8px;}
.session-tree-child.session-item:hover{opacity:1;}

View File

@@ -137,8 +137,10 @@ class TestSessionRenameMenuItem:
src = SESSIONS.read_text(encoding="utf-8")
# Row-attached closure invocation.
assert "row._startRename" in src
# Row lookup by data-sid.
# Row lookup by data-sid must include nested fork rows too.
assert "function _findSessionRenameRow(" in src
assert ".session-item[data-sid=" in src
assert ".session-child-session[data-sid=" in src
def test_row_exposes_start_rename(self):
"""The session row builder must attach `_startRename` to the row

View File

@@ -11,22 +11,25 @@ Verifies:
8. git-branch icon exists in icons.js
"""
import re
from pathlib import Path
def _read(path: str) -> str:
return Path(path).read_text(encoding="utf-8")
# ── Backend ────────────────────────────────────────────────────────────────────
def test_branch_endpoint_exists():
"""Verify the POST /api/session/branch route handler exists."""
with open('api/routes.py') as f:
src = f.read()
src = _read('api/routes.py')
assert '"POST /api/session/branch"' in src or '"/api/session/branch"' in src, \
"Missing /api/session/branch route"
def test_branch_endpoint_validates_session_id():
"""Verify the branch endpoint requires session_id."""
with open('api/routes.py') as f:
src = f.read()
src = _read('api/routes.py')
# Find the branch block
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
@@ -40,8 +43,7 @@ def test_branch_endpoint_validates_session_id():
def test_branch_endpoint_returns_new_session_id():
"""Verify the branch endpoint returns session_id and title."""
with open('api/routes.py') as f:
src = f.read()
src = _read('api/routes.py')
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
@@ -56,8 +58,7 @@ def test_branch_endpoint_returns_new_session_id():
def test_branch_creates_session_with_parent():
"""Verify the branch creates a Session with parent_session_id set."""
with open('api/routes.py') as f:
src = f.read()
src = _read('api/routes.py')
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
@@ -70,8 +71,7 @@ def test_branch_creates_session_with_parent():
def test_branch_marks_explicit_forks_as_fork_sessions():
"""Explicit branches must not be mistaken for compression lineage rows."""
with open('api/routes.py') as f:
src = f.read()
src = _read('api/routes.py')
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
@@ -83,21 +83,71 @@ def test_branch_marks_explicit_forks_as_fork_sessions():
def test_branch_fork_sessions_do_not_collapse_into_parent_lineage():
"""Forks remain selectable rows even if their parent is not in the current list."""
with open('static/sessions.js') as f:
src = f.read()
"""Fork sessions are not collapsed into compression-lineage; guard must remain in _sessionLineageKey."""
src = _read('static/sessions.js')
fn = re.search(r'function _sessionLineageKey\(.*?\n\}', src, re.DOTALL)
assert fn, "Could not find _sessionLineageKey"
block = fn.group(0)
assert "if(s.session_source==='fork') return null;" in block, \
"Explicit fork sessions should not collapse via parent_session_id"
"Fork guard must remain in _sessionLineageKey to prevent compression-lineage merging"
assert block.index("if(s.session_source==='fork') return null;") < block.index('return s.parent_session_id || null')
def test_branch_fork_sessions_nest_under_parent():
"""Forks with a resolvable in-list parent are subgrouped via _isForkWithResolvableParent
and fed into _attachChildSessionsToSidebarRows, not rendered as flat top-level rows."""
src = _read('static/sessions.js')
# Helper must exist
assert 'function _isForkWithResolvableParent(' in src, \
"Missing _isForkWithResolvableParent helper"
# _attachChildSessionsToSidebarRows must check for fork children
fn = re.search(r'function _attachChildSessionsToSidebarRows\(.*?\n\}', src, re.DOTALL)
assert fn, "Could not find _attachChildSessionsToSidebarRows"
block = fn.group(0)
assert '_isForkWithResolvableParent' in block, \
"_attachChildSessionsToSidebarRows must route fork children via _isForkWithResolvableParent"
# _resolveSessionIdFromSidebarLineage must no longer skip fork rows wholesale
resolve_fn = re.search(
r'function _resolveSessionIdFromSidebarLineage\(.*?\n\}', src, re.DOTALL)
assert resolve_fn, "Could not find _resolveSessionIdFromSidebarLineage"
resolve_block = resolve_fn.group(0)
assert "row.session_source==='fork'" not in resolve_block, \
"_resolveSessionIdFromSidebarLineage must not skip fork rows; they may now be active nested children"
assert "!_isChildSession(s)&&((s&&s.pinned)||!_isForkWithResolvableParent(s, sessionIdsInList))" in block, \
"Only unpinned resolvable fork rows should be filtered out of the top-level rows array"
def test_branch_nested_fork_rows_keep_session_actions():
"""Nested fork rows should keep the standard session action menu path."""
src = _read('static/sessions.js')
assert 'session-child-session-fork' in src, \
"Missing fork-specific nested child row path"
assert '_openSessionActionMenu(child, menuBtn)' in src, \
"Nested fork rows should route the standard session action menu"
assert 'row._startRename=_buildSessionRenameStarter(child, mainBtn' in src, \
"Nested fork rows should expose the same rename entry point as top-level rows"
def test_branch_nested_fork_search_results_auto_expand():
"""Nested fork hits should stay visible while sidebar search is active."""
src = _read('static/sessions.js')
assert "(_expandedChildSessionKeys.has(lineageKey)||!!searchQueryRaw)" in src, \
"Search-active fork matches should auto-expand their nested child group"
def test_branch_nested_fork_rows_render_their_own_state_indicator():
"""Expanded fork rows should keep unread/streaming/attention affordances."""
src = _read('static/sessions.js')
css = _read('static/style.css')
assert "session-state-indicator session-child-session-state" in src, \
"Nested fork rows should render a per-row state indicator"
assert "session-child-session-fork.streaming" in css, \
"Nested fork rows should expose row-level streaming styling"
def test_branch_keep_count_support():
"""Verify the branch endpoint supports keep_count parameter."""
with open('api/routes.py') as f:
src = f.read()
src = _read('api/routes.py')
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
@@ -111,8 +161,7 @@ def test_branch_keep_count_support():
def test_branch_auto_title():
"""Verify fork title defaults to '<original> (fork)'."""
with open('api/routes.py') as f:
src = f.read()
src = _read('api/routes.py')
branch_match = re.search(
r'parsed\.path == "/api/session/branch"(.*?)(?=\n if parsed\.path|$)',
src, re.DOTALL
@@ -126,8 +175,7 @@ def test_branch_auto_title():
def test_session_model_parent_session_id():
"""Verify Session model supports parent_session_id."""
with open('api/models.py') as f:
src = f.read()
src = _read('api/models.py')
assert 'parent_session_id' in src, "Session model should have parent_session_id"
# Check __init__ parameter
assert 'parent_session_id: str=None' in src, \
@@ -139,8 +187,7 @@ def test_session_model_parent_session_id():
def test_session_compact_includes_parent():
"""Verify compact() includes parent_session_id."""
with open('api/models.py') as f:
src = f.read()
src = _read('api/models.py')
# Find the compact method and scan its full body for parent_session_id.
# PR #1591 (May 2026) added a has_pending_user_message recompute block at
# the top of compact() which pushed the parent_session_id field beyond a
@@ -155,8 +202,7 @@ def test_session_compact_includes_parent():
def test_session_metadata_fields_includes_parent():
"""Verify parent_session_id is in METADATA_FIELDS for persistence."""
with open('api/models.py') as f:
src = f.read()
src = _read('api/models.py')
assert "'parent_session_id'" in src, \
"METADATA_FIELDS should include parent_session_id"
@@ -165,24 +211,21 @@ def test_session_metadata_fields_includes_parent():
def test_branch_slash_command_registered():
"""Verify /branch is registered as a slash command."""
with open('static/commands.js') as f:
src = f.read()
src = _read('static/commands.js')
assert "name:'branch'" in src, "/branch should be registered as a command"
assert 'cmdBranch' in src, "cmdBranch handler should be defined"
def test_cmdBranch_function_exists():
"""Verify cmdBranch function is defined."""
with open('static/commands.js') as f:
src = f.read()
src = _read('static/commands.js')
assert 'async function cmdBranch(' in src, \
"cmdBranch should be an async function"
def test_cmdBranch_calls_branch_endpoint():
"""Verify cmdBranch calls the /api/session/branch endpoint."""
with open('static/commands.js') as f:
src = f.read()
src = _read('static/commands.js')
branch_fn = re.search(r'async function cmdBranch\(.*?\n\}', src, re.DOTALL)
assert branch_fn, "Could not find cmdBranch function"
block = branch_fn.group(0)
@@ -192,8 +235,7 @@ def test_cmdBranch_calls_branch_endpoint():
def test_cmdBranch_switches_session():
"""Verify cmdBranch calls loadSession after branching."""
with open('static/commands.js') as f:
src = f.read()
src = _read('static/commands.js')
branch_fn = re.search(r'async function cmdBranch\(.*?\n\}', src, re.DOTALL)
assert branch_fn
block = branch_fn.group(0)
@@ -205,16 +247,14 @@ def test_cmdBranch_switches_session():
def test_forkFromMessage_function_exists():
"""Verify forkFromMessage function exists."""
with open('static/commands.js') as f:
src = f.read()
src = _read('static/commands.js')
assert 'async function forkFromMessage(' in src, \
"forkFromMessage should be defined"
def test_forkFromMessage_passes_keep_count():
"""Verify forkFromMessage passes keep_count to the endpoint."""
with open('static/commands.js') as f:
src = f.read()
src = _read('static/commands.js')
fn = re.search(r'async function forkFromMessage\(.*?\n\}', src, re.DOTALL)
assert fn
block = fn.group(0)
@@ -226,8 +266,7 @@ def test_forkFromMessage_passes_keep_count():
def test_fork_button_rendered_in_ui():
"""Verify fork button is rendered in message actions."""
with open('static/ui.js') as f:
src = f.read()
src = _read('static/ui.js')
assert "forkBtn" in src, "forkBtn variable should exist in ui.js"
assert "fork_from_here" in src, \
"fork_from_here i18n key should be referenced for tooltip"
@@ -237,8 +276,7 @@ def test_fork_button_rendered_in_ui():
def test_fork_button_in_message_actions():
"""Verify fork button is included in the msg-actions span."""
with open('static/ui.js') as f:
src = f.read()
src = _read('static/ui.js')
# The footHtml template should include forkBtn
assert '${forkBtn}' in src, \
"forkBtn should be included in message actions template"
@@ -248,8 +286,7 @@ def test_fork_button_in_message_actions():
def test_sidebar_parent_indicator():
"""Verify parent session indicator is rendered in session list."""
with open('static/sessions.js') as f:
src = f.read()
src = _read('static/sessions.js')
assert 'parent_session_id' in src, \
"sessions.js should check parent_session_id"
assert 'session-branch-indicator' in src, \
@@ -262,8 +299,7 @@ def test_sidebar_parent_indicator():
def test_parent_indicator_not_clickable():
"""Verify parent indicator is informational, not hidden navigation."""
with open('static/sessions.js') as f:
src = f.read()
src = _read('static/sessions.js')
# Find the parent indicator block
parent_block = re.search(
r'branch-indicator[\s\S]*?parent_session_id[\s\S]*?titleRow\.appendChild',
@@ -279,8 +315,7 @@ def test_parent_indicator_not_clickable():
def test_parent_indicator_tooltip_uses_parent_title_fallback():
"""Tooltip should prefer a parent title and only fall back to a short id."""
with open('static/sessions.js') as f:
src = f.read()
src = _read('static/sessions.js')
assert 'function _sessionTitleForForkParent' in src, \
"sessions.js should resolve a user-facing parent title"
assert 'function _truncatedSessionId' in src, \
@@ -291,8 +326,7 @@ def test_parent_indicator_tooltip_uses_parent_title_fallback():
def test_parent_indicator_hover_only_style():
"""The sidebar lineage indicator should be visually subdued until row hover/focus."""
with open('static/style.css') as f:
src = f.read()
src = _read('static/style.css')
assert '.session-branch-indicator' in src, \
"Missing session branch indicator CSS"
assert 'opacity:.35' in src, \
@@ -305,8 +339,7 @@ def test_parent_indicator_hover_only_style():
def test_i18n_branch_keys():
"""Verify all branch-related i18n keys exist in English locale."""
with open('static/i18n.js') as f:
src = f.read()
src = _read('static/i18n.js')
required_keys = [
'cmd_branch',
'cmd_branch_usage',
@@ -324,7 +357,6 @@ def test_i18n_branch_keys():
def test_git_branch_icon_exists():
"""Verify git-branch icon is defined in icons.js."""
with open('static/icons.js') as f:
src = f.read()
src = _read('static/icons.js')
assert "'git-branch'" in src, \
"git-branch icon should be defined in LI_PATHS"

View File

@@ -79,6 +79,6 @@ def test_child_session_open_uses_is_external_session():
"""Child session open handler must use _isExternalSession."""
js = _read_js()
assert re.search(
r'if\s*\(\s*_isExternalSession\s*\(\s*child\s*\)\s*\)',
r'if\s*\(\s*_isExternalSession\s*\(\s*(?:child|childSession)\s*\)\s*\)',
js,
), 'Child session open must use _isExternalSession(child)'
), 'Child session open must use _isExternalSession for the selected child session'

View File

@@ -198,7 +198,7 @@ def test_polling_transition_tracks_the_same_effective_streaming_state_as_sidebar
assert "isActive && Boolean(S.busy)" in local_block
assert "INFLIGHT && INFLIGHT[s.session_id]" not in local_block
assert "s.is_streaming || _isSessionLocallyStreaming(s)" in effective_block
assert "const isStreaming=_isSessionEffectivelyStreaming(s);" in render_block, (
assert "const ownStreaming=_isSessionEffectivelyStreaming(s)" in render_block, (
"the row spinner and polling completion transition must use the same "
"effective streaming source, including local INFLIGHT-only streams"
)
@@ -215,8 +215,8 @@ def test_cache_render_seeds_streaming_transition_state_for_visible_spinners():
assert "if (!s || !s.session_id || !isStreaming) return;" in remember_block
assert "_sessionStreamingById.set(s.session_id, true);" in remember_block
assert "const isStreaming=_isSessionEffectivelyStreaming(s);" in render_block
assert "_rememberRenderedStreamingState(s, isStreaming);" in render_block, (
assert "const ownStreaming=_isSessionEffectivelyStreaming(s)" in render_block
assert "_rememberRenderedStreamingState(s, ownStreaming);" in render_block, (
"renderSessionListFromCache can display a spinner from local INFLIGHT "
"state before a full poll runs, so it must seed the transition map too"
)

View File

@@ -140,7 +140,7 @@ def test_sidebar_uses_local_inflight_state_for_immediate_spinner():
assert "function _purgeStaleInflightEntries()" in SESSIONS_JS
assert "delete INFLIGHT[sid];" in SESSIONS_JS
assert "function _isSessionEffectivelyStreaming(s)" in SESSIONS_JS
assert "const isStreaming=_isSessionEffectivelyStreaming(s);" in SESSIONS_JS
assert "const ownStreaming=_isSessionEffectivelyStreaming(s)" in SESSIONS_JS
assert "if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();" in messages_js

View File

@@ -22,6 +22,7 @@ def _run_node(source: str) -> str:
input=source,
cwd=str(REPO_ROOT),
capture_output=True,
encoding="utf-8",
text=True,
timeout=10,
)
@@ -355,6 +356,7 @@ function extractFunc(name) {{
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sessionLineageKey'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_collapseSessionLineageForSidebar'));
@@ -392,6 +394,7 @@ function extractFunc(name) {{
return src.slice(start, i);
}}
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const collapsed = [{{session_id:'telegram_parent', title:'Telegram parent', source_label:'Telegram'}}];
@@ -418,6 +421,249 @@ console.log(JSON.stringify(rows));
assert "_child_sessions" not in rows[0]
def test_fork_child_with_visible_parent_is_nested_once():
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('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', updated_at:20, last_message_at:20}};
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert [row["session_id"] for row in rows] == ["parent"]
assert rows[0]["_child_session_count"] == 1
assert [child["session_id"] for child in rows[0]["_child_sessions"]] == ["fork1"]
def test_fork_child_without_visible_parent_stays_top_level():
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('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'missing', updated_at:20, last_message_at:20}};
const rows = _attachChildSessionsToSidebarRows([fork], [fork]);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert [row["session_id"] for row in rows] == ["fork1"]
assert "_child_sessions" not in rows[0]
def test_pinned_fork_with_visible_parent_stays_top_level():
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('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', pinned:true, updated_at:20, last_message_at:20}};
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert [row["session_id"] for row in rows] == ["parent", "fork1"]
assert "_child_sessions" not in rows[0]
def test_nested_fork_keeps_parent_timestamp_for_sorting():
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('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
const fork = {{session_id:'fork1', title:'Fork', session_source:'fork', parent_session_id:'parent', updated_at:20, last_message_at:20}};
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert rows[0]["session_id"] == "parent"
assert rows[0]["last_message_at"] == 10
def test_nested_fork_bubbles_parent_attention_state():
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);
}}
function _isSessionEffectivelyStreaming(session) {{
return !!(session && session.active_stream_id);
}}
function _hasUnreadForSession(session) {{
return !!(session && session.has_unread);
}}
eval(extractFunc('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_sessionDisplayTitle'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const parent = {{session_id:'parent', title:'Parent', updated_at:10, last_message_at:10}};
const fork = {{
session_id:'fork1',
title:'Fork',
session_source:'fork',
parent_session_id:'parent',
updated_at:20,
last_message_at:20,
has_unread:true,
attention:{{kind:'approval', count:2}},
active_stream_id:'stream-1'
}};
const rows = _attachChildSessionsToSidebarRows([parent, fork], [parent, fork]);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert rows[0]["_child_session_has_unread"] is True
assert rows[0]["_child_session_streaming"] is True
assert rows[0]["_child_session_attention"]["kind"] == "approval"
def test_fork_chain_stays_attached_under_visible_root():
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('_sessionTimestampMs'));
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_sessionDisplayTitle'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
const root = {{session_id:'root', title:'Root', updated_at:10, last_message_at:10}};
const fork1 = {{session_id:'fork1', title:'Fork 1', session_source:'fork', parent_session_id:'root', updated_at:20, last_message_at:20}};
const fork2 = {{session_id:'fork2', title:'Fork 2', session_source:'fork', parent_session_id:'fork1', updated_at:30, last_message_at:30}};
const rows = _attachChildSessionsToSidebarRows([fork2, fork1, root], [fork2, fork1, root]);
console.log(JSON.stringify(rows));
"""
rows = json.loads(_run_node(source))
assert [row["session_id"] for row in rows] == ["root"]
assert [child["session_id"] for child in rows[0]["_child_sessions"]] == ["fork1", "fork2"]
assert rows[0]["_child_sessions"][1]["_parent_segment_id"] == "fork1"
def test_sidebar_lineage_key_uses_session_id_for_fork_rows():
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('_sidebarLineageKeyForRow'));
const root = {{session_id:'root', parent_session_id:null}};
const pinnedFork = {{session_id:'fork1', session_source:'fork', parent_session_id:'root', pinned:true}};
console.log(JSON.stringify({{
rootKey:_sidebarLineageKeyForRow(root),
forkKey:_sidebarLineageKeyForRow(pinnedFork),
}}));
"""
result = json.loads(_run_node(source))
assert result["rootKey"] == "root"
assert result["forkKey"] == "fork1"
def test_session_segment_count_prefers_visible_collapsed_backend_and_materialized_counts():
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
source = f"""
@@ -484,7 +730,8 @@ def test_lineage_segment_expansion_static_contract():
assert "const segTitle=_sessionDisplayTitle(seg)||t('session_lineage_segment_untitled');" in js
assert "row.title=t('session_lineage_segment_open');" in js
assert "await loadSession(seg.session_id, {skipLineageResolve:true});" in js
assert "await loadSession(child.session_id, {skipLineageResolve:true});" in js
assert "const openChildSession=async(childSession)=>{" in js
assert "await loadSession(childSession.session_id, {skipLineageResolve:true});" in js
assert "if(!opts.skipLineageResolve && typeof _resolveSessionIdFromSidebarLineage==='function'){" in js
assert ".session-lineage-count.expandable{" in css
assert ".session-lineage-count.expandable:hover" in css
@@ -819,6 +1066,7 @@ function extractFunc(name) {{
return src.slice(start, i);
}}
eval(extractFunc('_isChildSession'));
eval(extractFunc('_isForkWithResolvableParent'));
eval(extractFunc('_sidebarLineageKeyForRow'));
eval(extractFunc('_sessionDisplayTitle'));
eval(extractFunc('_attachChildSessionsToSidebarRows'));
@@ -871,3 +1119,38 @@ console.log(JSON.stringify({{
}}));
"""
assert json.loads(_run_node(source)) == {"webui": [], "custom": ["#prod"]}
def test_streaming_state_recorded_from_own_state_not_bubbled_child():
"""_rememberRenderedStreamingState must receive the parent's own streaming
state, not the composite own||child value. Otherwise the parent gets
marked unread/completed when a nested fork stops streaming."""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
# The pattern we need: ownStreaming used for remember, isStreaming used
# for rendering (includes child).
assert "const ownStreaming=_isSessionEffectivelyStreaming(s);" in js
assert "const isStreaming=ownStreaming||!!s._child_session_streaming;" in js
assert "_rememberRenderedStreamingState(s, ownStreaming);" in js
# The old buggy pattern must not exist.
assert "_rememberRenderedStreamingState(s, isStreaming);" not in js
def test_nested_fork_rows_included_in_visible_sidebar_ids():
"""Expanded writable fork children must appear in _sessionVisibleSidebarIds
so they participate in batch-select (select-all / shift-select)."""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
assert "child.session_source==='fork'" in js
# The _sessionVisibleSidebarIds builder must push fork children.
assert "_sessionVisibleSidebarIds.push(child.session_id)" in js
def test_nested_fork_rows_render_select_checkbox():
"""The session-child-session-fork render path must include a batch-select
checkbox when _sessionSelectMode is active and the child is writable."""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
render_marker = "row.className='session-child-session session-child-session-fork'"
fork_render_start = js.find(render_marker)
assert fork_render_start > 0
fork_render_block = js[fork_render_start:fork_render_start + 2000]
assert "session-select-cb" in fork_render_block
assert "_sessionSelectMode" in fork_render_block

View File

@@ -14,9 +14,9 @@ SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8")
def _session_rename_block():
start = SESSIONS_JS.find("const startRename=()=>{")
assert start >= 0, "session inline rename startRename() block not found"
end = SESSIONS_JS.find("// (Project dot is appended above", start)
start = SESSIONS_JS.find("function _buildSessionRenameStarter(")
assert start >= 0, "shared session rename helper not found"
end = SESSIONS_JS.find("function _appendSessionCopyLinkAction(", start)
assert end > start, "session inline rename block end marker not found"
return SESSIONS_JS[start:end]
@@ -36,12 +36,12 @@ def test_session_rename_finish_is_idempotent():
def test_session_rename_guard_releases_after_save_path_completes():
block = _session_rename_block()
assert block.count("_renamingSid = null;") == 1, (
assert block.count("_renamingSid=null;") == 1, (
"_renamingSid must be cleared from one release helper only, not at the "
"top of finish() before the async rename save settles"
)
release_pos = block.find("const releaseRename=()=>{")
clear_pos = block.find("_renamingSid = null;")
clear_pos = block.find("_renamingSid=null;")
assert release_pos >= 0 and clear_pos > release_pos, (
"_renamingSid should be cleared inside releaseRename(), after the "
"selected finish path has completed"
@@ -57,9 +57,10 @@ def test_session_rename_guard_releases_after_save_path_completes():
def test_session_rename_success_updates_cache_and_active_session_title():
block = _session_rename_block()
assert "_allSessions.find(item=>item&&item.session_id===s.session_id)" in block
assert "if(cached) cached.title=nextTitle;" in block
assert "S.session.title=nextTitle;syncTopbar();" in block, (
assert "_allSessions.find(item=>item&&item.session_id===session.session_id)" in block
assert "target.display_title=nextTitle;" in block
assert "target._state_db_title=nextTitle;" in block
assert "if(S.session&&S.session.session_id===session.session_id){applyLocalTitle(S.session, nextTitle);syncTopbar();}" in block, (
"successful session rename must keep cached and active titles coherent"
)
@@ -75,3 +76,11 @@ def test_session_rename_failure_restores_state_and_surfaces_error():
)
assert "setStatus(msg);" in catch_block
assert "showToast(msg,3000,'error')" in catch_block
def test_session_rename_blur_commits_current_title():
block = _session_rename_block()
assert "inp.onblur=()=>{ if(_renamingSid===session.session_id) finish(true); };" in block, (
"blur should commit the edited title so rename matches the long-standing "
"top-level session behavior"
)

View File

@@ -49,6 +49,15 @@ def test_mobile_session_menu_opens_from_long_press_and_hides_dots():
assert ".session-item:focus-within,.session-item.menu-open{padding-right:6px;}" in mobile_touch
def test_nested_fork_mobile_menu_uses_long_press_fallback():
assert "const _scheduleForkLongPressMenu=()=>{" in SESSIONS_JS
assert "rowEl.classList.add('long-pressing')" in SESSIONS_JS
assert "_openSessionActionMenu(childSession, rowEl);" in SESSIONS_JS
assert "rowEl._skipNextChildOpen=true;" in SESSIONS_JS
assert "if(row._skipNextChildOpen){" in SESSIONS_JS
assert ".session-child-session-fork.long-pressing{" in STYLE_CSS
def test_open_session_menu_consumes_next_row_activation():
context_menu = _sessions_block("el.oncontextmenu=(e)=>{", "// Use release events")
assert SESSIONS_JS.count("el.oncontextmenu=(e)=>{") == 1