Merge pull request #4168 from nesquena/stage-3601b
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user