diff --git a/static/sessions.js b/static/sessions.js index 5dda0ba9d4..6725ebde73 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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)); + 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}`:''; + 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)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':''); - 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}`; + 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 diff --git a/static/style.css b/static/style.css index 8a4d10c0ce..04af1315d1 100644 --- a/static/style.css +++ b/static/style.css @@ -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,26 @@ 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.needs-attention{box-shadow:inset 3px 0 0 var(--warning);background:color-mix(in srgb,var(--warning) 8%,transparent);} +.session-child-session-fork.attention-approval{box-shadow:inset 3px 0 0 var(--error);background:color-mix(in srgb,var(--error) 9%,transparent);} +.session-child-session-fork.attention-clarify{box-shadow:inset 3px 0 0 var(--warning);background:color-mix(in srgb,var(--warning) 8%,transparent);} +.session-child-session-fork.dragging{transition:background .15s,color .15s,box-shadow .15s ease;will-change:transform;} +.session-child-session-fork.dragging:hover{background:transparent;color:var(--muted);} +.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;} diff --git a/tests/test_1764_context_menu_essentials.py b/tests/test_1764_context_menu_essentials.py index 1d51d54b4c..04a5e12a3a 100644 --- a/tests/test_1764_context_menu_essentials.py +++ b/tests/test_1764_context_menu_essentials.py @@ -137,8 +137,10 @@ def test_rename_dispatches_to_row_closure(self): 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 diff --git a/tests/test_465_session_branching.py b/tests/test_465_session_branching.py index 2a3722f8e2..21c3d46501 100644 --- a/tests/test_465_session_branching.py +++ b/tests/test_465_session_branching.py @@ -11,22 +11,25 @@ 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 ' (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" diff --git a/tests/test_issue3603_external_session_import_gate.py b/tests/test_issue3603_external_session_import_gate.py index fabad248ec..fd923ec1ef 100644 --- a/tests/test_issue3603_external_session_import_gate.py +++ b/tests/test_issue3603_external_session_import_gate.py @@ -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' diff --git a/tests/test_issue856_background_completion_unread.py b/tests/test_issue856_background_completion_unread.py index c9f5113f2f..e23bf79dcc 100644 --- a/tests/test_issue856_background_completion_unread.py +++ b/tests/test_issue856_background_completion_unread.py @@ -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" ) diff --git a/tests/test_issue856_pinned_indicator_layout.py b/tests/test_issue856_pinned_indicator_layout.py index 56f37afb86..2130441e9b 100644 --- a/tests/test_issue856_pinned_indicator_layout.py +++ b/tests/test_issue856_pinned_indicator_layout.py @@ -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 diff --git a/tests/test_session_lineage_collapse.py b/tests/test_session_lineage_collapse.py index 9f81553641..5a45a234fc 100644 --- a/tests/test_session_lineage_collapse.py +++ b/tests/test_session_lineage_collapse.py @@ -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 @@ def test_sidebar_attaches_child_sessions_to_collapsed_hidden_parent_lineage(): }} eval(extractFunc('_sessionTimestampMs')); eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); eval(extractFunc('_sessionLineageKey')); eval(extractFunc('_sidebarLineageKeyForRow')); eval(extractFunc('_collapseSessionLineageForSidebar')); @@ -392,6 +394,7 @@ def test_cross_surface_webui_child_session_remains_top_level_when_parent_is_mess 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 @@ def test_cross_surface_webui_child_session_remains_top_level_when_parent_is_mess 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 @@ def test_child_session_parent_segment_note_uses_display_title(): return src.slice(start, i); }} eval(extractFunc('_isChildSession')); +eval(extractFunc('_isForkWithResolvableParent')); eval(extractFunc('_sidebarLineageKeyForRow')); eval(extractFunc('_sessionDisplayTitle')); eval(extractFunc('_attachChildSessionsToSidebarRows')); @@ -871,3 +1119,38 @@ def test_default_webui_numbered_titles_are_not_treated_as_hash_tags(): }})); """ 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 diff --git a/tests/test_session_rename_lifecycle.py b/tests/test_session_rename_lifecycle.py index 50a3ab0c9c..c300d42c98 100644 --- a/tests/test_session_rename_lifecycle.py +++ b/tests/test_session_rename_lifecycle.py @@ -14,9 +14,9 @@ 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" + ) diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 5cacb3c52a..2c73e610ae 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -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