diff --git a/CHANGELOG.md b/CHANGELOG.md index f4914957..813ac5c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.167] — 2026-05-30 — Release EM (stage-batch49 — iOS-style swipe actions for touch devices + session-list FLIP reflow) + +### Added + +- Touch devices now support iOS-mail-style swipe actions on session rows: swipe left to reveal a Delete action, swipe right to reveal Archive (Restore for already-archived sessions). Swipe is gated to touch/coarse-pointer input, so desktop click, context-menu, and drag behavior are unchanged. Delete still routes through the existing confirmation dialog. The session list also gains FLIP-based reflow animation when rows are archived, deleted, or reordered, honoring `prefers-reduced-motion`. + ## [v0.51.166] — 2026-05-30 — Release EL (stage-batch48 — shared OpenCode runtime key + cron project-chip sessions) ### Fixed diff --git a/static/icons.js b/static/icons.js index 65fd1020..6aedb459 100644 --- a/static/icons.js +++ b/static/icons.js @@ -21,6 +21,7 @@ const LI_PATHS = { 'upload': '', 'braces': '', 'trash-2': '', + 'archive': '', 'settings': '', 'alert-triangle': '', 'refresh-cw': '', diff --git a/static/sessions.js b/static/sessions.js index c76360db..7a484797 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -107,6 +107,14 @@ let _sessionListLastScrollAt = 0; let _pendingSessionListPayload = null; let _pendingSessionListApplyTimer = 0; const SESSION_LIST_INTERACTION_IDLE_MS = 700; +const SESSION_SWIPE_DURATION_MS = 500; +const SESSION_SWIPE_REFLOW_LEAD_MS = 220; +const SESSION_REFLOW_TIMEOUT_MS = 420; +const SESSION_LIST_FLIP_TIMEOUT_MS = 460; +const SESSION_LONG_PRESS_DELAY_MS = 400; +const SESSION_ARCHIVE_SWIPE_THRESHOLD_PX = 128; +const SESSION_DELETE_SWIPE_THRESHOLD_PX = 128; +const SESSION_SWIPE_CANCEL_RATIO = 0.75; function _formatSessionModelWithGateway(s){ if(!s||!s.model)return''; @@ -1607,6 +1615,89 @@ const _lineageReportCache = new Map(); const _lineageReportInflight = new Map(); let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; +let _pendingSessionReflowPositions = null; +const _optimisticallyRemovedSessionIds = new Set(); +const _sessionSwipeReturnOffsets = new Map(); + +function _captureSessionReflowPositions(){ + const list=$('sessionList'); + if(!list) return null; + const positions=new Map(); + list.querySelectorAll('.session-item[data-sid]').forEach(row=>{ + positions.set(row.dataset.sid,row.getBoundingClientRect().top); + }); + return positions; +} + +function _waitForSessionMotion(ms){ + return new Promise(resolve=>setTimeout(resolve,ms)); +} + +function _playSessionRowsReflowFromPositions(before, timeoutMs, prefersReducedMotion){ + if(!before||!before.size) return; + if(prefersReducedMotion&&prefersReducedMotion()) return; + const list=$('sessionList'); + if(!list) return; + const movingRows=[]; + list.querySelectorAll('.session-item[data-sid]').forEach(row=>{ + const oldTop=before.get(row.dataset.sid); + if(oldTop===undefined) return; + const delta=oldTop-row.getBoundingClientRect().top; + if(Math.abs(delta)<1) return; + movingRows.push({row,delta}); + }); + if(!movingRows.length) return; + movingRows.forEach(({row,delta})=>{ + row.style.transition='none'; + row.style.setProperty('--session-reflow-offset',delta+'px'); + row.classList.add('session-reflowing'); + }); + list.getBoundingClientRect(); + movingRows.forEach(({row})=>{ + let reflowCleared=false; + const clearReflow=()=>{ + if(reflowCleared) return; + reflowCleared=true; + row.classList.remove('session-reflowing'); + row.style.removeProperty('--session-reflow-offset'); + row.removeEventListener('transitionend',onReflowEnd); + }; + const onReflowEnd=(event)=>{ + if(event.propertyName==='transform') clearReflow(); + }; + row.addEventListener('transitionend',onReflowEnd); + row.style.removeProperty('transition'); + requestAnimationFrame(()=>requestAnimationFrame(()=>{ + if(!reflowCleared) row.style.setProperty('--session-reflow-offset','0px'); + })); + setTimeout(clearReflow,timeoutMs); + }); +} + +function _sessionPrefersReducedMotion(){ + try{ + return Boolean(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches); + }catch(_){ + return false; + } +} + +function _makeSessionSwipeAffordance(side, icon, label){ + const affordance=document.createElement('div'); + affordance.className='session-swipe-affordance session-swipe-affordance-'+side; + affordance.setAttribute('aria-hidden','true'); + const stack=document.createElement('span'); + stack.className='session-swipe-action-stack'; + const badge=document.createElement('span'); + badge.className='session-swipe-badge'; + badge.innerHTML=li(icon,18); + const text=document.createElement('span'); + text.className='session-swipe-label'; + text.textContent=label; + stack.append(badge,text); + affordance.append(stack); + return affordance; +} const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; const SESSION_VIRTUAL_THRESHOLD_ROWS = 80; @@ -1860,9 +1951,11 @@ function closeSessionActionMenu(){ _sessionActionMenu = null; } if(_sessionActionAnchor){ - _sessionActionAnchor.classList.remove('active'); + if(_sessionActionAnchor.classList&&_sessionActionAnchor.classList.contains('session-actions-trigger')){ + _sessionActionAnchor.classList.remove('active'); + } const row=_sessionActionAnchor.closest('.session-item'); - if(row) row.classList.remove('menu-open'); + if(row) row.classList.remove('menu-open','long-pressing'); _sessionActionAnchor = null; } _sessionActionSessionId = null; @@ -1925,6 +2018,46 @@ function _appendSessionDuplicateAction(menu, session){ )); } +function _playSessionActionMenuEntrance(menu){ + if(!menu) return; + const reduce=_sessionPrefersReducedMotion(); + if(reduce) return; + if(typeof menu.animate==='function'){ + try{ + const anim=menu.animate( + [ + {opacity:0, transform:'translate3d(0,-4px,0) scale(.985)'}, + {opacity:1, transform:'translate3d(0,0,0) scale(1)'} + ], + {duration:450, easing:'cubic-bezier(.2,.8,.2,1)'} + ); + if(anim&&anim.finished) anim.finished.catch(()=>{}); + return; + }catch(_){} + } + menu.classList.add('open-animated'); +} + +async function _archiveSession(session, archived=true, beforeListRender=null){ + if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return false; } + const reflowPositions=_captureSessionReflowPositions(); + const renderHold=beforeListRender?Promise.resolve().then(beforeListRender):null; + try{ + const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived})}); + session.archived=archived; + const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id); + if(cached) cached.archived=archived; + if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived; + showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); + if(renderHold) await renderHold; + if(_showArchived&&!_sessionPrefersReducedMotion()) _sessionSwipeReturnOffsets.set(session.session_id,'0px'); + _pendingSessionReflowPositions=reflowPositions; + renderSessionListFromCache(); + void renderSessionList(); + return true; + }catch(err){if(renderHold) await renderHold.catch(()=>{});_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;} +} + function _openSessionActionMenu(session, anchorEl){ if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return; } if(_sessionActionMenu && _sessionActionSessionId===session.session_id && _sessionActionAnchor===anchorEl){ @@ -1936,7 +2069,7 @@ function _openSessionActionMenu(session, anchorEl){ const isCliSession = _isCliSession(session); const isExternalSession = isMessagingSession || isCliSession; const menu=document.createElement('div'); - menu.className='session-action-menu open'; + menu.className='session-action-menu'; // Rename — first menu item by request (#1764). Double-click rename is // timing-sensitive: the first click frequently registers as "open the // chat" before the second click arrives, so users open the conversation @@ -1998,14 +2131,7 @@ function _openSessionActionMenu(session, anchorEl){ session.archived?ICONS.unarchive:ICONS.archive, async()=>{ closeSessionActionMenu(); - try{ - const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived:!session.archived})}); - _optimisticallyArchiveSessionInList(session.session_id,!session.archived); - session.archived=!session.archived; - if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived; - void renderSessionList(); - showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); - }catch(err){showToast(t('session_archive_failed')+err.message);} + await _archiveSession(session,!session.archived); } )); if(isExternalSession && !session.archived){ @@ -2069,10 +2195,11 @@ function _openSessionActionMenu(session, anchorEl){ _sessionActionMenu = menu; _sessionActionAnchor = anchorEl; _sessionActionSessionId = session.session_id; - anchorEl.classList.add('active'); + if(anchorEl.classList&&anchorEl.classList.contains('session-actions-trigger')) anchorEl.classList.add('active'); const row=anchorEl.closest('.session-item'); if(row) row.classList.add('menu-open'); _positionSessionActionMenu(anchorEl); + _playSessionActionMenuEntrance(menu); } document.addEventListener('click',e=>{ @@ -2109,48 +2236,6 @@ function animateNextSessionListRefresh(options={}){ if(options&&options.enterAll) _sessionListEnterAllAnimationPending = true; } -function _captureSessionListFlipPositions(){ - const list=$('sessionList'); - if(!list) return null; - const positions=new Map(); - list.querySelectorAll('.session-item[data-sid]').forEach(row=>{ - positions.set(row.dataset.sid,row.getBoundingClientRect().top); - }); - return positions; -} - -function _sessionListPrefersReducedMotion(){ - try{return window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;} - catch(_){return false;} -} - -function _playSessionListFlipAnimation(before){ - if(!before||!before.size||_sessionListPrefersReducedMotion()) return; - const list=$('sessionList'); - if(!list) return; - list.querySelectorAll('.session-item[data-sid]').forEach(row=>{ - const oldTop=before.get(row.dataset.sid); - if(oldTop===undefined) return; - const delta=oldTop-row.getBoundingClientRect().top; - if(Math.abs(delta)<1) return; - row.style.setProperty('--session-reflow-offset',delta+'px'); - row.classList.add('session-reflowing'); - row.getBoundingClientRect(); - row.style.setProperty('--session-reflow-offset','0px'); - let cleared=false; - const clear=()=>{ - if(cleared) return; - cleared=true; - row.classList.remove('session-reflowing'); - row.style.removeProperty('--session-reflow-offset'); - row.removeEventListener('transitionend',onEnd); - }; - const onEnd=(event)=>{ if(event.propertyName==='transform') clear(); }; - row.addEventListener('transitionend',onEnd); - setTimeout(clear,460); - }); -} - function _isOptimisticFirstTurnSessionRow(s){ if(!s||!s.session_id||s.archived) return false; const messageCount=Number(s.message_count||0); @@ -2273,8 +2358,11 @@ function _applySessionListPayload(sessData, projData){ if (typeof sessData.server_tz === 'string') { _serverTz = sessData.server_tz; } - _reconcileActiveSessionIdleStateFromList(sessData.sessions||[]); - _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); + const serverSessions=_optimisticallyRemovedSessionIds.size + ? (sessData.sessions||[]).filter(s=>s&&!_optimisticallyRemovedSessionIds.has(s.session_id)) + : (sessData.sessions||[]); + _reconcileActiveSessionIdleStateFromList(serverSessions); + _allSessions = _mergeOptimisticFirstTurnSessions(serverSessions); _clearLineageReportCache(); _allProjects = projData.projects||[]; _markPollingCompletionUnreadTransitions(_allSessions); @@ -3394,7 +3482,9 @@ function renderSessionListFromCache(){ _sessionListRefreshAnimationPending=false; const enterAllAnimatedRows=animateRefresh&&_sessionListEnterAllAnimationPending; _sessionListEnterAllAnimationPending=false; - const flipBefore=animateRefresh?_captureSessionListFlipPositions():null; + const flipBefore=animateRefresh?_captureSessionReflowPositions():null; + const committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS; + const committedSwipeReflowDelay=Math.max(0,committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS); const listScrollTopBeforeRender=list.scrollTop||0; list.innerHTML=''; // Batch select bar (when in select mode) @@ -3654,9 +3744,12 @@ function renderSessionListFromCache(){ toggleBtn.onclick=(e)=>{e.stopPropagation();toggleSessionSelectMode();}; list.appendChild(toggleBtn); } - if(animateRefresh){ - _playSessionListFlipAnimation(flipBefore); - } + // Refresh FLIP and queued archive/delete reflow both drive + // --session-reflow-offset. Refresh wins so one render has one transform writer. + const reflowBefore=animateRefresh?flipBefore:_pendingSessionReflowPositions; + const reflowTimeout=animateRefresh?SESSION_LIST_FLIP_TIMEOUT_MS:SESSION_REFLOW_TIMEOUT_MS; + _pendingSessionReflowPositions=null; + _playSessionRowsReflowFromPositions(reflowBefore,reflowTimeout,_sessionPrefersReducedMotion); // Note: declared after the groups loop but available via function hoisting. function _renderOneSession(s, isPinnedGroup=false){ const el=document.createElement('div'); @@ -3667,6 +3760,16 @@ function renderSessionListFromCache(){ const hasUnread=_hasUnreadForSession(s)&&!isActive; 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':''); + const swipeReturnOffset=_sessionSwipeReturnOffsets.get(s.session_id); + if(swipeReturnOffset!==undefined){ + _sessionSwipeReturnOffsets.delete(s.session_id); + el.style.setProperty('--session-swipe-return-offset',swipeReturnOffset); + el.classList.add('session-swipe-returning'); + el.addEventListener('animationend',()=>{ + el.classList.remove('session-swipe-returning'); + el.style.removeProperty('--session-swipe-return-offset'); + },{once:true}); + } if(animateRefresh&&(enterAllAnimatedRows||!(flipBefore&&flipBefore.has(s.session_id)))){ el.classList.add('session-list-flip-enter'); } @@ -3996,6 +4099,9 @@ function renderSessionListFromCache(){ 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(); @@ -4007,6 +4113,7 @@ function renderSessionListFromCache(){ el.oncontextmenu=(e)=>{ if(readOnly) return; e.preventDefault(); + if(e.pointerType==='touch'||e.pointerType==='pen') return; e.stopPropagation(); clearTimeout(_tapTimer); _tapTimer=null; @@ -4015,78 +4122,242 @@ function renderSessionListFromCache(){ _openSessionActionMenu(s, actions||el); }; - // Use pointerup + manual double-tap detection instead of onclick/ondblclick. + if(!readOnly){ + el.append( + _makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive')), + _makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')), + ); + } + + // Use release events + manual double-tap detection instead of onclick/ondblclick. // onclick/ondblclick are unreliable on touch devices (iPad Safari especially): // hover-triggered layout shifts, ghost clicks, and 300ms delay all break - // single-tap navigation. pointerup fires immediately on both mouse & touch. + // single-tap navigation. // Mouse clicks are instant; touch presses need a 300ms delay to distinguish // a tap from a scroll-drag gesture on mobile. - // Drag detection (pointermove > 5px) cancels the pending tap on release. + // Movement promotes pressing into dragging; drag release cancels a pending tap. let _lastTapTime=0; let _tapTimer=null; let _pointerDownX=0; let _pointerDownY=0; - let _pointerActive=false; - let _isDragging=false; + let _gestureState='idle'; // idle | pressing | dragging | committed let _clearDragTimer=null; - const _clearPointerDragState=()=>{ - _pointerActive=false; - if(_isDragging){ - _isDragging=false; - if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} - _clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50); - } + let _longPressTimer=null; + let _longPressMenuOpened=false; + let _swipeTracking=false; + let _pointerX=0; + let _pointerY=0; + let _gesturePointerType=''; + const _clearLongPressTimer=()=>{ + if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} + if(!_longPressMenuOpened) el.classList.remove('long-pressing'); }; - el.onpointerdown=(e)=>{ - if(e.pointerType==='mouse' && e.button!==0) return; - _pointerActive=true; - _pointerDownX=e.clientX; - _pointerDownY=e.clientY; - _isDragging=false; + const _beginSessionGesture=(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;} - el.classList.remove('dragging'); + el.classList.remove('dragging','swipe-committed','swipe-removing'); + el.style.removeProperty('height'); + el.style.removeProperty('min-height'); }; - el.onpointermove=(e)=>{ - // Plain hover also dispatches pointermove. Only mark a row as dragging - // after an actual press starts on this row; otherwise hovered rows stay - // faded until the next sidebar rerender clears their DOM nodes. - if(!_pointerActive) return; - if(_isDragging) return; - const dx=Math.abs(e.clientX-_pointerDownX); - const dy=Math.abs(e.clientY-_pointerDownY); - if(dx>5||dy>5){ - _isDragging=true; - el.classList.add('dragging'); - // Cancel any pending drag-clear so we don't flash hover mid-drag - if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} - } - }; - el.onpointercancel=_clearPointerDragState; - el.onpointerleave=()=>{ if(_pointerActive) _clearPointerDragState(); }; - el.onpointerup=(e)=>{ - if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click - _pointerActive=false; - if(_renamingSid) return; - if(actions&&actions.contains(e.target)) return; - if(e.target&&e.target.closest&&e.target.closest('.session-child-count,.session-child-sessions,.session-child-session,.session-lineage-count,.session-lineage-segments,.session-lineage-segment')) return; - if(_sessionSelectMode){e.stopPropagation();if(!readOnly)toggleSessionSelect(s.session_id);return;} - // If the pointer moved enough to be a drag, cancel any pending tap - if(_isDragging){clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0;_clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50);return;} - const now=Date.now(); - if(now-_lastTapTime<350){ - // Double-tap: rename + const _scheduleSessionLongPressMenu=()=>{ + _clearLongPressTimer(); + el.classList.add('long-pressing'); + _longPressTimer=setTimeout(()=>{ + if(_gestureState!=='pressing'||_renamingSid||_sessionSelectMode||readOnly) return; + _longPressMenuOpened=true; clearTimeout(_tapTimer); _tapTimer=null; _lastTapTime=0; - startRename(); + _openSessionActionMenu(s, el); + },SESSION_LONG_PRESS_DELAY_MS); + }; + const _isSessionSwipeTarget=()=>{ + return _gesturePointerType!=='mouse'&&!readOnly&&!_renamingSid&&!_sessionSelectMode; + }; + const _isSessionActionTarget=(target)=>{ + return !!(actions&&target&&actions.contains(target)); + }; + const _trackHorizontalSwipe=(dx,dy)=>{ + if(dx>8&&dx>dy*1.1) _swipeTracking=true; + }; + const _promoteSessionDrag=(dx,dy)=>{ + if(_gestureState!=='pressing'||(dx<=5&&dy<=5)) return; + if(dy>8||dx>10) _clearLongPressTimer(); + _gestureState='dragging'; + el.classList.add('dragging'); + if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} + }; + const _updateSessionGesture=(clientX,clientY)=>{ + if(_gestureState==='idle') return false; + _pointerX=clientX; + _pointerY=clientY; + const signedDx=clientX-_pointerDownX; + const signedDy=clientY-_pointerDownY; + const dx=Math.abs(signedDx); + const dy=Math.abs(signedDy); + _promoteSessionDrag(dx,dy); + _trackHorizontalSwipe(dx,dy); + if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx); + return _swipeTracking; + }; + const _canSwipeDeleteSession=()=>{ + return _isSessionSwipeTarget()&&!_isMessagingSession(s)&&!_isCliSession(s); + }; + const _paintSessionSwipe=(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)); + el.style.setProperty('--session-swipe-offset',offset+'px'); + el.style.setProperty('--session-swipe-reveal',reveal+'px'); + el.style.setProperty('--session-swipe-badge-size',badgeSize+'px'); + el.style.setProperty('--session-swipe-icon-size',iconSize+'px'); + el.style.setProperty('--session-swipe-label-scale',labelScale); + el.style.setProperty('--session-swipe-badge-stretch',badgeStretch+'px'); + el.style.setProperty('--session-swipe-progress',actionOpacity); + el.classList.toggle('swiping-right',offset>0); + el.classList.toggle('swiping-left',offset<0); + }; + const _clearSessionSwipePaint=()=>{ + el.style.removeProperty('--session-swipe-offset'); + el.style.removeProperty('--session-swipe-reveal'); + el.style.removeProperty('--session-swipe-badge-size'); + el.style.removeProperty('--session-swipe-icon-size'); + el.style.removeProperty('--session-swipe-label-scale'); + el.style.removeProperty('--session-swipe-badge-stretch'); + el.style.removeProperty('--session-swipe-progress'); + el.style.removeProperty('height'); + el.style.removeProperty('min-height'); + el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing'); + }; + const _settleSessionSwipePaint=()=>{ + el.classList.remove('dragging'); + requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint)); + }; + const _completeSessionSwipePaint=(signedDx)=>{ + el.classList.remove('dragging'); + el.classList.add('swipe-committed'); + el.style.setProperty('--session-swipe-progress','0'); + el.style.setProperty('--session-swipe-offset',(signedDx>0?1:-1)*window.innerWidth+'px'); + const rect=el.getBoundingClientRect(); + el.style.height=rect.height+'px'; + el.style.minHeight=rect.height+'px'; + requestAnimationFrame(()=>el.classList.add('swipe-removing')); + }; + const _handleSessionSwipe=(signedDx,signedDy)=>{ + if(_gestureState==='committed'||!_isSessionSwipeTarget()) 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'; + _clearLongPressTimer(); + clearTimeout(_tapTimer); + _tapTimer=null; + _lastTapTime=0; + if(signedDx>0){ + if(s.archived){ + _settleSessionSwipePaint(); + _archiveSession(s,false,()=>_waitForSessionMotion(committedSwipeDuration)).then((restored)=>{ + if(!restored) _settleSessionSwipePaint(); + }); + }else if(_showArchived){ + _settleSessionSwipePaint(); + _archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeDuration)).then((archived)=>{ + if(!archived) _settleSessionSwipePaint(); + }); + }else{ + _completeSessionSwipePaint(signedDx); + _archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeReflowDelay)).then((archived)=>{ + if(!archived) _settleSessionSwipePaint(); + }); + } + }else if(_canSwipeDeleteSession()){ + el.classList.remove('dragging'); + deleteSession(s.session_id,async()=>{ + _completeSessionSwipePaint(signedDx); + await _waitForSessionMotion(committedSwipeReflowDelay); + }).then((deleted)=>{ + if(!deleted) _settleSessionSwipePaint(); + }); + }else if(typeof showToast==='function'){ + showToast('Imported sessions cannot be deleted here.',3000); + _gestureState='dragging'; + _settleSessionSwipePaint(); + } + return true; + }; + const _commitSessionSwipe=()=>{ + return _handleSessionSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY); + }; + const _clearPointerDragState=()=>{ + if(_gestureState==='committed'){ + _clearLongPressTimer(); return; } + const wasDragging=_gestureState==='dragging'||_swipeTracking; + _gestureState='idle'; + _clearLongPressTimer(); + if(wasDragging){ + if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} + _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); + } + }; + const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{ + const wasDragging=_gestureState==='dragging'||_swipeTracking; + _clearLongPressTimer(); + if(_renamingSid){_gestureState='idle';return false;} + if(_isSessionActionTarget(target)){_gestureState='idle';return false;} + _pointerX=clientX; + _pointerY=clientY; + _commitSessionSwipe(); + if(_longPressMenuOpened){_gestureState='idle';return true;} + if(_gestureState==='committed') return true; + if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){ + closeSessionActionMenu(); + return true; + } + if(target&&target.closest&&target.closest('.session-child-count,.session-child-sessions,.session-child-session,.session-lineage-count,.session-lineage-segments,.session-lineage-segment')) return false; + if(_sessionSelectMode){if(!readOnly)toggleSessionSelect(s.session_id);return true;} + if(wasDragging){ + clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0; + _gestureState='idle'; + _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); + return false; + } + _gestureState='idle'; + const now=Date.now(); + if(now-_lastTapTime<350){ + clearTimeout(_tapTimer); + _tapTimer=null; + _lastTapTime=0; + el.classList.remove('loading'); + startRename(); + return false; + } _lastTapTime=now; - // Single tap: wait to ensure it's not the first of a double-tap, - // then navigate. Mouse is instant; touch needs delay to suppress - // accidental navigation during scroll-drag lifts. clearTimeout(_tapTimer); - const delay=e.pointerType==='mouse'?0:300; + const delay=pointerType==='mouse'?0:300; + if(pointerType!=='mouse') el.classList.add('loading'); _tapTimer=setTimeout(async()=>{ _tapTimer=null; _lastTapTime=0; @@ -4097,10 +4368,43 @@ function renderSessionListFromCache(){ await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:s.session_id})}); }catch(e){ /* import failed -- fall through to read-only view */ } } - if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true; - await loadSession(s.session_id);renderSessionListFromCache(); - if(typeof closeMobileSidebar==='function')closeMobileSidebar(); + try{ + if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true; + await loadSession(s.session_id);renderSessionListFromCache(); + if(typeof closeMobileSidebar==='function')closeMobileSidebar(); + }finally{ + el.classList.remove('loading'); + } }, delay); + return false; + }; + el.onpointerdown=(e)=>{ + if(e.pointerType==='touch') return; + if(e.pointerType==='mouse' && e.button!==0) return; + if(_isSessionActionTarget(e.target)) return; + _beginSessionGesture(e.clientX,e.clientY,e.pointerType||''); + if(e.pointerType==='pen'){ + _scheduleSessionLongPressMenu(); + } + }; + el.onpointermove=(e)=>{ + if(e.pointerType==='touch') return; + // Plain hover also dispatches pointermove. Only mark a row as dragging + // after an actual press starts on this row; otherwise hovered rows stay + // faded until the next sidebar rerender clears their DOM nodes. + _updateSessionGesture(e.clientX,e.clientY); + }; + el.onpointercancel=(e)=>{ + if(e.pointerType==='touch') return; + _clearPointerDragState(); + }; + el.onpointerleave=()=>{ + if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState(); + }; + el.onpointerup=(e)=>{ + if(e.pointerType==='touch') return; + if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click + if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation(); }; // Add ondblclick for more reliable double-click detection el.ondblclick=(e)=>{ @@ -4112,6 +4416,24 @@ function renderSessionListFromCache(){ if (_loadingSessionId && _loadingSessionId !== s.session_id) return; startRename(); }; + el.addEventListener('touchstart',(e)=>{ + if(_isSessionActionTarget(e.target)) return; + const touch=e.changedTouches&&e.changedTouches[0]; + if(!touch) return; + _beginSessionGesture(touch.clientX,touch.clientY,'touch'); + _scheduleSessionLongPressMenu(); + },{passive:true}); + el.addEventListener('touchmove',(e)=>{ + const touch=e.changedTouches&&e.changedTouches[0]; + if(!touch) return; + if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault(); + },{passive:false}); + el.addEventListener('touchcancel',_clearPointerDragState,{passive:true}); + el.addEventListener('touchend',(e)=>{ + const touch=e.changedTouches&&e.changedTouches[0]; + if(!touch) return; + if(_finishSessionGesture(touch.clientX,touch.clientY,e.target,'touch')) e.stopPropagation(); + },{passive:true}); return el; } } @@ -4215,20 +4537,46 @@ async function removeWorktree(session){ } } -async function deleteSession(sid){ +async function deleteSession(sid, beforeDelete=null){ const session=_sessionSnapshotById(sid); const ok=await showConfirmDialog({ message:session&&session.worktree_path?t('session_delete_worktree_confirm',session.worktree_path):t('session_delete_confirm'), confirmLabel:t('delete_title'), danger:true }); - if(!ok)return; - let response=null; - try{ - response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); - _optimisticallyRemoveSessionFromList(sid); + if(!ok)return false; + const reflowPositions=_captureSessionReflowPositions(); + const beforeDeleteHold=beforeDelete?Promise.resolve().then(beforeDelete):null; + const previousSessions=_allSessions; + let optimisticRendered=false; + const deleteRequest=api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}).then(response=>{ _clearHandoffStorageForSession(sid); - }catch(e){setStatus(`Delete failed: ${e.message}`);return;} + return {response}; + }, error=>({error})); + if(beforeDeleteHold){ + await beforeDeleteHold; + _optimisticallyRemovedSessionIds.add(sid); + _pendingSessionReflowPositions=reflowPositions; + _optimisticallyRemoveSessionFromList(sid); + optimisticRendered=true; + } + const deleteResult=await deleteRequest; + if(deleteResult&&deleteResult.error){ + _pendingSessionReflowPositions=null; + if(optimisticRendered){ + _optimisticallyRemovedSessionIds.delete(sid); + _allSessions=previousSessions; + renderSessionListFromCache(); + } + const err=deleteResult.error; + setStatus(`Delete failed: ${err&&err.message?err.message:String(err)}`); + return false; + } + const response=deleteResult&&deleteResult.response; + if(!optimisticRendered){ + _pendingSessionReflowPositions=reflowPositions; + _optimisticallyRemoveSessionFromList(sid); + } if(S.session&&S.session.session_id===sid){ S.session=null;S.messages=[];S.entries=[]; localStorage.removeItem('hermes-webui-session'); @@ -4247,7 +4595,9 @@ async function deleteSession(sid){ } } showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted')); - void renderSessionList(); + if(optimisticRendered) void renderSessionList().finally(()=>_optimisticallyRemovedSessionIds.delete(sid)); + else await renderSessionList(); + return true; } // ── Project helpers ───────────────────────────────────────────────────── diff --git a/static/style.css b/static/style.css index 15b6650b..90b36834 100644 --- a/static/style.css +++ b/static/style.css @@ -549,7 +549,6 @@ :root:not(.dark) *{scrollbar-color:rgba(0,0,0,.15) transparent;} /* ── Light mode: sidebar, roles, chips, active states ── */ :root:not(.dark) .session-item{color:var(--muted);} - :root:not(.dark) .session-item:hover{background:var(--hover-bg);color:var(--text);} :root:not(.dark) .session-item.active{background:var(--accent-bg);color:var(--accent-text);} :root:not(.dark) .session-item.active .session-title{color:var(--accent-text);} :root:not(.dark) .session-pin-indicator{color:var(--accent-text);} @@ -724,17 +723,127 @@ and attention indicator (26x26 at right:6px) still need 40px reserved when they're visible — covered by the hover / streaming / unread / menu-open / focus-within rule below. */ - .session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1);display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:manipulation;-webkit-tap-highlight-color:transparent;transform:translateY(var(--session-reflow-offset,0));} + .session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;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),var(--session-reflow-offset,0),0);} .session-item.streaming,.session-item.unread,.session-item:focus-within,.session-item.menu-open{padding-right:40px;} - .session-item:hover{background:var(--hover-bg);color:var(--text);} + @media (hover:hover){.session-item:hover{background:var(--hover-bg);color:var(--text);}} + .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-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; + } + .session-swipe-affordance-right{ + left:0; + color:#fff; + background:transparent; + border-radius:0; + align-items:flex-start; + transform-origin:left center; + --session-swipe-action-color:var(--warning); + } + .session-swipe-affordance-left{ + right:0; + color:#fff; + background:transparent; + border-radius:0; + align-items:flex-end; + transform-origin:right center; + --session-swipe-action-color:var(--error); + } + .session-item.swiping-right .session-swipe-affordance-right, + .session-item.swiping-left .session-swipe-affordance-left{ + opacity:var(--session-swipe-progress,0); + } + .session-text,.session-state-indicator,.session-actions{z-index:1;} + .session-swipe-action-stack{ + width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px)); + max-width:100%; + display:flex; + flex-direction:column; + align-items:center; + gap:3px; + flex:0 0 auto; + } + .session-swipe-badge{ + width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px)); + height:var(--session-swipe-badge-size,34px); + border-radius:999px; + background:var(--session-swipe-action-color); + display:flex; + align-items:center; + justify-content:center; + color:#fff; + flex:0 0 auto; + overflow:hidden; + } + .session-swipe-badge svg{ + display:block; + width:var(--session-swipe-icon-size,18px); + height:var(--session-swipe-icon-size,18px); + stroke-width:2.2; + } + .session-swipe-label{ + min-width:58px; + width:100%; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + text-align:center; + color:var(--muted); + font-size:10px; + font-weight:600; + line-height:1; + transform:scale(var(--session-swipe-label-scale,1)); + transform-origin:top center; + } + .session-swipe-affordance-right .session-swipe-action-stack{transform:translateX(6px);} + .session-swipe-affordance-left .session-swipe-action-stack{transform:translateX(-6px);} + .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;will-change:transform;} + .session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;} + .session-item.swipe-committed, + .session-item.swipe-removing{transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease;will-change:transform;} + .session-item.swipe-removing{ + overflow:hidden; + } + .session-item.session-swipe-returning{animation:sessionSwipeReturn .5s cubic-bezier(.22,.61,.36,1) both;will-change:transform,opacity;} + .session-item.swipe-committed .session-swipe-affordance{transition:opacity .18s ease,transform .18s ease;} /* Suppress hover highlight during drag to avoid visual noise mid-scroll */ .session-item.dragging:hover{background:transparent;color:var(--muted);} + .session-item.dragging.swiping-right{background:color-mix(in srgb,var(--warning) 16%,var(--surface));} + .session-item.dragging.swiping-left{background:color-mix(in srgb,var(--error) 14%,var(--surface));} /* Restore hover padding-right only for mouse (hover:hover) devices. Touch/iPad (hover:none) must NOT expand padding-right on :hover — the expansion causes a layout-reflow mid-tap that moves session-actions under the finger, triggering stopPropagation and swallowing navigation. */ - @media (hover:hover){.session-item:hover{padding-right:40px;}} + @media (hover:hover){:root:not(.dark) .session-item:hover{background:var(--hover-bg);color:var(--text);}.session-item:hover{padding-right:40px;}} .session-item.active{background:var(--accent-bg);color:var(--accent);} + .session-item.active.swiping-right{background:color-mix(in srgb,var(--warning) 20%,var(--accent-bg));} + .session-item.active.swiping-left{background:color-mix(in srgb,var(--error) 18%,var(--accent-bg));} + .session-item.archived .session-swipe-affordance-right{--session-swipe-action-color:var(--success);} + .session-item.archived.swiping-right, + .session-item.archived.dragging.swiping-right{background:color-mix(in srgb,var(--success) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--success) 48%,transparent);} + .session-item.active.archived.swiping-right{background:color-mix(in srgb,var(--success) 20%,var(--accent-bg));} .session-item.streaming .session-title{color:var(--accent);} .session-item.streaming .session-title-row{color:var(--text);} .session-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow:hidden;} @@ -798,8 +907,8 @@ .session-actions-trigger:hover{background:var(--hover-bg);color:var(--text);} .session-actions-trigger.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);color:var(--text);} .session-actions-trigger svg{display:block;} - .session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:320px;overflow-y:auto;} - .session-action-menu.open{display:block;} + .session-action-menu{display:block;position:fixed;left:0;top:0;right:auto;bottom:auto;min-width:220px;max-width:min(280px,calc(100vw - 16px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:999;overflow:hidden;max-height:calc(100vh - 16px);overflow-y:auto;transform-origin:top right;will-change:opacity,transform;} + .session-action-menu.open-animated{animation:session-menu-in .45s cubic-bezier(.2,.8,.2,1);} .session-action-opt{width:100%;background:none;border:none;text-align:left;font:inherit;color:var(--text);flex-direction:row!important;gap:0!important;padding:0!important;} .session-action-opt .ws-opt-action{display:flex;flex-direction:row;align-items:center;gap:10px;width:100%;padding:10px 14px;} .session-action-opt .ws-opt-icon{color:var(--muted);transition:color .12s,opacity .12s;flex-shrink:0;display:flex;align-items:center;width:16px;} @@ -811,19 +920,22 @@ .session-action-opt.is-disabled:hover{background:transparent;} .session-action-opt.danger:hover{background:rgba(239,83,80,.08);} .session-action-opt.danger .ws-opt-icon,.session-action-opt.danger .ws-opt-name{color:var(--error);} + @media (prefers-reduced-motion:reduce){ + .session-action-menu{animation:none;will-change:auto;} + .session-item,.session-item.dragging,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;} + .session-item.long-pressing,.session-item.session-swipe-returning{animation:none;} + .session-item.session-list-flip-enter{animation:none;} + } /* Hide overlay during inline rename */ .session-item:has(.session-title-input) .session-actions{display:none;} + @keyframes session-menu-in{from{opacity:0;transform:translate3d(0,-4px,0) scale(.985);}to{opacity:1;transform:translate3d(0,0,0) scale(1);}} + @keyframes session-long-press{from{filter:brightness(1);}to{filter:brightness(1.08);}} + @keyframes sessionSwipeReturn{0%{opacity:.62;transform:translate3d(var(--session-swipe-return-offset,32px),var(--session-reflow-offset,0),0) scale(.985);}100%{opacity:1;transform:translate3d(0,var(--session-reflow-offset,0),0) scale(1);}} @keyframes newflash{0%{background:var(--accent-bg-strong);color:var(--accent);}100%{background:transparent;color:var(--muted);}} @keyframes sessionListFlipIn{0%{opacity:0;transform:perspective(700px) rotateX(-8deg) translateY(10px) scale(.985);}100%{opacity:1;transform:perspective(700px) rotateX(0) translateY(0) scale(1);}} @keyframes spin{to{transform:rotate(360deg);}} .session-item.new-flash{animation:newflash 1.4s ease-out forwards;} - .session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1);will-change:transform;} .session-item.session-list-flip-enter{animation:sessionListFlipIn .28s cubic-bezier(.2,.8,.2,1) backwards;transform-origin:center top;} - @media (prefers-reduced-motion: reduce){ - .session-item, - .session-item.session-reflowing{transition:background .15s,color .15s;} - .session-item.session-list-flip-enter{animation:none;} - } /* Collapsible date group headers */ .session-date-header{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 10px 4px;cursor:pointer;user-select:none;opacity:.8;transition:opacity .15s;} .session-date-header:hover{opacity:1;} @@ -2004,6 +2116,13 @@ .suggestion{font-size:12px;padding:10px 12px;} } + @media (hover:none) and (pointer:coarse){ + .session-actions{display:none;} + .session-item{padding-right:6px;} + .session-item.streaming,.session-item.unread{padding-right:40px;} + .session-item:focus-within,.session-item.menu-open{padding-right:6px;} + } + @media (max-width: 340px){ /* Extreme legacy-phone widths (e.g. 320px) cannot fit five 44px left-side touch targets plus the fixed send button with the normal diff --git a/tests/conftest.py b/tests/conftest.py index 5982dd12..57ca4ba9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,6 +74,15 @@ os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR) # tests that read/write config.yaml stay inside the isolated test home. os.environ['HERMES_CONFIG_PATH'] = str(TEST_STATE_DIR / 'config.yaml') + +@pytest.fixture(autouse=True) +def _isolate_hermes_config_path(): + """Keep profile/.env side effects from leaking the live config path across tests.""" + isolated_config_path = str(TEST_STATE_DIR / 'config.yaml') + os.environ['HERMES_CONFIG_PATH'] = isolated_config_path + yield + os.environ['HERMES_CONFIG_PATH'] = isolated_config_path + # ── Server script: always relative to repo root ─────────────────────────── SERVER_SCRIPT = REPO_ROOT / 'server.py' if not SERVER_SCRIPT.exists(): diff --git a/tests/test_issue2454_active_session_spinner.py b/tests/test_issue2454_active_session_spinner.py index 33a3f242..dd6bb357 100644 --- a/tests/test_issue2454_active_session_spinner.py +++ b/tests/test_issue2454_active_session_spinner.py @@ -55,14 +55,16 @@ def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state(): def test_session_list_payload_reconciles_active_idle_state_before_optimistic_merge_and_render(): body = _function_body(SESSIONS_SRC, "function _applySessionListPayload(") - reconcile_pos = body.find("_reconcileActiveSessionIdleStateFromList(sessData.sessions||[])") + filter_pos = body.find("const serverSessions=_optimisticallyRemovedSessionIds.size") + reconcile_pos = body.find("_reconcileActiveSessionIdleStateFromList(serverSessions)") merge_pos = body.find("_allSessions = _mergeOptimisticFirstTurnSessions") render_pos = body.find("renderSessionListFromCache()") + assert filter_pos != -1, "payload application must filter optimistic removals before row reconciliation" assert reconcile_pos != -1, "active-session idle reconciliation must run for refreshed rows" assert merge_pos != -1, "session rows must still be applied from /api/sessions" assert render_pos != -1, "payload application must still render from cache" - assert reconcile_pos < merge_pos < render_pos, ( + assert filter_pos < reconcile_pos < merge_pos < render_pos, ( "local S.busy/INFLIGHT state must be reconciled against raw server rows " "before optimistic merging can re-label a stale active session as streaming" ) diff --git a/tests/test_issue856_pinned_indicator_layout.py b/tests/test_issue856_pinned_indicator_layout.py index 93b2d644..b7c13056 100644 --- a/tests/test_issue856_pinned_indicator_layout.py +++ b/tests/test_issue856_pinned_indicator_layout.py @@ -118,10 +118,10 @@ def test_timestamp_hidden_when_attention_state_is_present(): def test_plain_mouse_hover_does_not_mark_session_row_dragging(): """Pointermove fires during ordinary hover; drag styling must require an active press.""" - assert "let _pointerActive=false;" in SESSIONS_JS - assert "_pointerActive=true;" in SESSIONS_JS - assert "if(!_pointerActive) return;" in SESSIONS_JS - assert "_pointerActive=false;" in SESSIONS_JS + assert "let _gestureState='idle';" in SESSIONS_JS + assert "_gestureState='pressing';" in SESSIONS_JS + assert "if(_gestureState==='idle') return false;" in SESSIONS_JS + assert "_gestureState='idle';" in SESSIONS_JS assert ".session-item.dragging:hover" in STYLE_CSS diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index 4f0cb6e8..9dbe6597 100644 --- a/tests/test_profile_switch_ux.py +++ b/tests/test_profile_switch_ux.py @@ -1,11 +1,10 @@ """ -Tests for profile-switch UX improvements — spinner indicator + parallelized fetches. +Tests for profile-switch UX improvements. -Two changes: -1. switchToProfile() shows a spinner on the profile chip during the async switch, - with an optimistic name update and error revert. -2. loadWorkspaceList() refreshes in the background and the model catalog is - invalidated for lazy refresh instead of holding the visible switch animation open. +Covered behavior: +- switchToProfile() shows a spinner during the async switch and reverts on error. +- Non-visible refresh work runs after the visible switch completes. +- Session-list refreshes animate rows with row-level FLIP motion. """ import re from pathlib import Path @@ -165,42 +164,33 @@ class TestProfileSessionListFlip: JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") - def test_profile_refresh_captures_row_positions(self): - assert "function _captureSessionListFlipPositions()" in self.JS - start = self.JS.index("function _captureSessionListFlipPositions()") - end = self.JS.index("function _sessionListPrefersReducedMotion()", start) - fn = self.JS[start:end] - assert "querySelectorAll('.session-item[data-sid]')" in fn - assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in fn - - def test_profile_refresh_reflows_existing_rows(self): - assert "function _playSessionListFlipAnimation(before)" in self.JS - start = self.JS.index("function _playSessionListFlipAnimation(before)") - end = self.JS.index("function _isOptimisticFirstTurnSessionRow(s)", start) - fn = self.JS[start:end] - assert "const delta=oldTop-row.getBoundingClientRect().top;" in fn - assert "row.style.setProperty('--session-reflow-offset',delta+'px');" in fn - assert "row.classList.add('session-reflowing');" in fn - def test_profile_refresh_flips_new_rows(self): assert "session-list-flip-enter" in self.JS assert "@keyframes sessionListFlipIn" in self.CSS - assert "rotateX" in self.CSS def test_profile_refresh_captures_before_render_and_plays_after_rows_exist(self): - capture = self.JS.index("const flipBefore=animateRefresh?_captureSessionListFlipPositions():null;") + capture = self.JS.index("const flipBefore=animateRefresh?_captureSessionReflowPositions():null;") clear = self.JS.index("list.innerHTML='';", capture) row_render = self.JS.index("body.appendChild(_renderOneSession", clear) - play = self.JS.index("_playSessionListFlipAnimation(flipBefore);", row_render) + play = self.JS.index("_playSessionRowsReflowFromPositions(reflowBefore,reflowTimeout,_sessionPrefersReducedMotion);", row_render) assert capture < clear < row_render < play + def test_profile_refresh_drops_queued_reflow_before_playing_flip(self): + start = self.JS.index("// Refresh FLIP and queued archive/delete reflow both drive") + end = self.JS.index("// Note: declared after the groups loop", start) + block = self.JS[start:end] + + assert "const reflowBefore=animateRefresh?flipBefore:_pendingSessionReflowPositions;" in block + assert "const reflowTimeout=animateRefresh?SESSION_LIST_FLIP_TIMEOUT_MS:SESSION_REFLOW_TIMEOUT_MS;" in block + assert "_pendingSessionReflowPositions=null;" in block + assert "_playSessionRowsReflowFromPositions(reflowBefore,reflowTimeout,_sessionPrefersReducedMotion);" in block + assert block.index("const reflowBefore=animateRefresh?flipBefore:_pendingSessionReflowPositions;") < block.index("const reflowTimeout=animateRefresh?SESSION_LIST_FLIP_TIMEOUT_MS:SESSION_REFLOW_TIMEOUT_MS;") + assert block.index("const reflowTimeout=animateRefresh?SESSION_LIST_FLIP_TIMEOUT_MS:SESSION_REFLOW_TIMEOUT_MS;") < block.index("_pendingSessionReflowPositions=null;") + assert block.index("_pendingSessionReflowPositions=null;") < block.index("_playSessionRowsReflowFromPositions(reflowBefore,reflowTimeout,_sessionPrefersReducedMotion);") + def test_first_non_empty_session_render_is_animated(self): assert "_sessionListFirstRenderAnimated" in self.JS assert "animateNextSessionListRefresh({enterAll:true});" in self.JS assert "_sessionListFirstRenderAnimated=true;" in self.JS - assert "if(S&&S._bootReady) _sessionListFirstRenderAnimated=true;" not in self.JS assert "enterAllAnimatedRows" in self.JS - - def test_profile_refresh_is_not_whole_list_fade(self): - assert "session-list.profile-refresh" not in self.CSS diff --git a/tests/test_session_action_menu_regression.py b/tests/test_session_action_menu_regression.py index 136b5882..05de2c55 100644 --- a/tests/test_session_action_menu_regression.py +++ b/tests/test_session_action_menu_regression.py @@ -33,24 +33,27 @@ def test_session_list_refresh_does_not_close_open_conversation_actions(): def test_archive_action_repaints_sidebar_before_full_refresh(): """Archive should hide the row from cached sidebar state before /api/sessions returns.""" - body = _function_block(SESSIONS_JS, "_openSessionActionMenu") + menu_body = _function_block(SESSIONS_JS, "_openSessionActionMenu") + helper_body = _function_block(SESSIONS_JS, "_archiveSession") api_call = "const response=await api('/api/session/archive'" - optimistic = "_optimisticallyArchiveSessionInList(session.session_id,!session.archived);" + optimistic = "if(cached) cached.archived=archived;" + cached_render = "renderSessionListFromCache();" full_refresh = "void renderSessionList();" - assert optimistic in body - assert body.index(api_call) < body.index(optimistic) < body.index(full_refresh) + assert "await _archiveSession(session,!session.archived);" in menu_body + assert optimistic in helper_body + assert helper_body.index(api_call) < helper_body.index(optimistic) < helper_body.index(cached_render) < helper_body.index(full_refresh) def test_delete_action_repaints_sidebar_before_loading_remaining_sessions(): """Delete should remove the row locally before loading replacement session data.""" body = _function_block(SESSIONS_JS, "deleteSession") - api_call = "response=await api('/api/session/delete'" + api_call = "const deleteRequest=api('/api/session/delete'" optimistic = "_optimisticallyRemoveSessionFromList(sid);" remaining_fetch = "const remaining=await api('/api/sessions');" - full_refresh = "void renderSessionList();" + full_refresh = "await renderSessionList();" assert optimistic in body assert body.index(api_call) < body.index(optimistic) < body.index(full_refresh) diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py new file mode 100644 index 00000000..5cacb3c5 --- /dev/null +++ b/tests/test_session_touch_actions.py @@ -0,0 +1,276 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") + + +def _block(source: str, start_marker: str, end_marker: str) -> str: + start = source.find(start_marker) + assert start != -1, f"{start_marker!r} not found" + end = source.find(end_marker, start) + assert end != -1, f"{end_marker!r} not found after {start_marker!r}" + return source[start:end] + + +def _sessions_block(start_marker: str, end_marker: str) -> str: + return _block(SESSIONS_JS, start_marker, end_marker) + + +def test_session_menu_uses_viewport_height_not_fixed_scroll_cap(): + assert "max-height:calc(100vh - 16px)" in STYLE_CSS + session_menu = STYLE_CSS[STYLE_CSS.find(".session-action-menu{"):STYLE_CSS.find(".session-action-menu.open")] + assert "max-height:320px" not in session_menu + + +def test_session_menu_respects_motion_preferences(): + session_menu = STYLE_CSS[STYLE_CSS.find(".session-action-menu{"):STYLE_CSS.find(".session-action-menu.open")] + assert "will-change:opacity,transform" in session_menu + assert "function _playSessionActionMenuEntrance(menu){" in SESSIONS_JS + assert "menu.classList.add('open-animated')" in SESSIONS_JS + assert "@media (prefers-reduced-motion:reduce)" in STYLE_CSS + assert ".session-action-menu{animation:none;will-change:auto;}" in STYLE_CSS + + +def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): + assert "const SESSION_LONG_PRESS_DELAY_MS = 400;" in SESSIONS_JS + assert "el.classList.add('long-pressing')" in SESSIONS_JS + assert "if(!_longPressMenuOpened) el.classList.remove('long-pressing')" in SESSIONS_JS + assert "row.classList.remove('menu-open','long-pressing')" in SESSIONS_JS + assert "_openSessionActionMenu(s, el)" in SESSIONS_JS + assert "@media (hover:none) and (pointer:coarse)" in STYLE_CSS + assert ".session-actions{display:none;}" in STYLE_CSS + assert "const _beginSessionGesture=(clientX,clientY,pointerType='')=>{" in SESSIONS_JS + assert "const _scheduleSessionLongPressMenu=()=>{" in SESSIONS_JS + mobile_touch = STYLE_CSS[STYLE_CSS.find("@media (hover:none) and (pointer:coarse)"):STYLE_CSS.find("@media (max-width: 340px)")] + assert ".session-item{padding-right:6px;}" in mobile_touch + assert ".session-item.streaming,.session-item.unread{padding-right:40px;}" in mobile_touch + assert ".session-item:focus-within,.session-item.menu-open{padding-right:6px;}" in mobile_touch + + +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 + assert "if(e.pointerType==='touch'||e.pointerType==='pen') return;" in context_menu + assert "_openSessionActionMenu(s, actions||el);" in context_menu + assert "if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){" in SESSIONS_JS + assert "closeSessionActionMenu();" in SESSIONS_JS + assert "e.stopPropagation();" in SESSIONS_JS + assert "const stopMenuPointer=(e)=>e.stopPropagation();" in SESSIONS_JS + assert "menuBtn.onpointerdown=stopMenuPointer;" in SESSIONS_JS + assert "menuBtn.onpointerup=stopMenuPointer;" in SESSIONS_JS + menu_btn_idx = SESSIONS_JS.find("menuBtn.onpointerdown=stopMenuPointer;") + menu_click_idx = SESSIONS_JS.find("menuBtn.onclick=(e)=>{", menu_btn_idx) + assert menu_btn_idx > 0 and menu_click_idx > menu_btn_idx + assert "const _isSessionActionTarget=(target)=>{" in SESSIONS_JS + assert "return !!(actions&&target&&actions.contains(target));" in SESSIONS_JS + assert "if(_isSessionActionTarget(e.target)) return;" in SESSIONS_JS + assert "if(_isSessionActionTarget(target)){_gestureState='idle';return false;}" in SESSIONS_JS + assert "if(_longPressMenuOpened){_gestureState='idle';return true;}" in SESSIONS_JS + finish_idx = SESSIONS_JS.find("const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{") + dismiss_idx = SESSIONS_JS.find("if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){", finish_idx) + load_idx = SESSIONS_JS.find("await loadSession(s.session_id)", finish_idx) + pointerup_idx = SESSIONS_JS.find("el.onpointerup=(e)=>{") + assert finish_idx > 0 and load_idx > finish_idx + assert dismiss_idx > finish_idx and dismiss_idx < load_idx + assert "if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation();" in SESSIONS_JS[pointerup_idx:] + + +def test_session_swipes_route_archive_restore_and_delete(): + assert "_gesturePointerType!=='mouse'" in SESSIONS_JS + assert "_swipeTracking=true" in SESSIONS_JS + assert "const SESSION_ARCHIVE_SWIPE_THRESHOLD_PX = 128;" in SESSIONS_JS + assert "const SESSION_DELETE_SWIPE_THRESHOLD_PX = 128;" in SESSIONS_JS + assert "const SESSION_SWIPE_DURATION_MS = 500;" in SESSIONS_JS + assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 220;" in SESSIONS_JS + swipe_block = _sessions_block("const _handleSessionSwipe=(signedDx,signedDy)=>{", "const _commitSessionSwipe=()=>{") + assert "const actionThreshold=signedDx>0?SESSION_ARCHIVE_SWIPE_THRESHOLD_PX:SESSION_DELETE_SWIPE_THRESHOLD_PX;" in SESSIONS_JS + assert "if(Math.abs(signedDx){" in SESSIONS_JS + assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS + assert "if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault();" in SESSIONS_JS + assert "_beginSessionGesture(touch.clientX,touch.clientY,'touch');" in SESSIONS_JS + restore_start = swipe_block.find("if(s.archived){") + archive_start = swipe_block.find("}else{", restore_start) + delete_start = swipe_block.find("}else if(_canSwipeDeleteSession()){", archive_start) + restore_branch = swipe_block[restore_start:archive_start] + archive_branch = swipe_block[archive_start:delete_start] + delete_branch = swipe_block[delete_start:] + assert "_settleSessionSwipePaint();" in restore_branch + assert "_completeSessionSwipePaint(signedDx);" not in restore_branch + assert "_archiveSession(s,false,()=>_waitForSessionMotion(committedSwipeDuration))" in restore_branch + archived_visible_start = swipe_block.find("}else if(_showArchived){") + assert archived_visible_start > restore_start + archived_visible_branch = swipe_block[archived_visible_start:archive_start] + assert "_settleSessionSwipePaint();" in archived_visible_branch + assert "_completeSessionSwipePaint(signedDx);" not in archived_visible_branch + assert "_archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeDuration))" in archived_visible_branch + assert archive_branch.find("_completeSessionSwipePaint(signedDx);") < archive_branch.find("_archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeReflowDelay))") + assert delete_branch.find("deleteSession(s.session_id,async()=>{") < delete_branch.find("_completeSessionSwipePaint(signedDx);") + assert "showToast('Imported sessions cannot be deleted here.',3000);" in SESSIONS_JS + assert "let _gestureState='idle';" in SESSIONS_JS + assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 + assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS + + +def test_session_swipe_paint_uses_transform_only_exit(): + paint = _sessions_block("const _paintSessionSwipe=(signedDx)=>{", "const _clearSessionSwipePaint=()=>{") + clear = _sessions_block("const _clearSessionSwipePaint=()=>{", "const _settleSessionSwipePaint=()=>{") + complete = _sessions_block("const _completeSessionSwipePaint=(signedDx)=>{", "const _handleSessionSwipe=(signedDx,signedDy)=>{") + assert "--session-swipe-offset" in paint + assert "--session-swipe-reveal" in paint + assert "--session-swipe-progress" in paint + assert "--session-swipe-badge-stretch" in paint + assert "const reveal=Math.abs(offset);" in paint + assert "const actionRevealScale=1.15;" in paint + assert "const iconScale=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint + assert "const badgeSize=34*iconScale;" in paint + assert "const iconSize=18*iconScale;" in paint + assert "const labelScale=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint + assert "const actionOpacity=Math.min(1,Math.max(.01,progress*actionRevealScale));" in paint + assert "const actionInset=6;" in paint + assert "const tileGap=6;" in paint + assert "const stretchStart=72/actionRevealScale;" in paint + assert "const stretchProgress=Math.max(0,reveal-stretchStart);" in paint + assert "const badgeStretch=Math.min(Math.max(0,reveal-34),stretchProgress*1.15,Math.max(0,reveal-badgeSize-actionInset-tileGap));" in paint + assert "el.style.setProperty('--session-swipe-badge-size',badgeSize+'px');" in paint + assert "el.style.setProperty('--session-swipe-icon-size',iconSize+'px');" in paint + assert "el.style.setProperty('--session-swipe-label-scale',labelScale);" in paint + assert "el.style.setProperty('--session-swipe-progress',actionOpacity);" in paint + assert "Math.pow(progress,1.5)" not in paint + assert "window.innerWidth+'px'" in complete + assert "el.style.height=rect.height+'px'" in complete + assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in complete + assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS + assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear + assert "el.style.removeProperty('--session-swipe-badge-size');" in clear + assert "el.style.removeProperty('--session-swipe-icon-size');" in clear + assert "el.style.removeProperty('--session-swipe-label-scale');" in clear + assert "el.style.removeProperty('--session-swipe-badge-stretch');" in clear + assert ".session-item.swipe-committed,\n .session-item.swipe-removing{transition:" in STYLE_CSS + assert "transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0)" in STYLE_CSS + assert "transform:scale(var(--session-swipe-icon-scale,1))" not in STYLE_CSS + swipe_start = STYLE_CSS.find(".session-item.swipe-removing{") + swipe_end = STYLE_CSS.find("}", swipe_start) + assert swipe_start >= 0 and swipe_end > swipe_start + swipe_removing = STYLE_CSS[swipe_start:swipe_end] + assert "height:0" not in swipe_removing + assert "padding-top:0" not in swipe_removing + assert "margin-bottom:0" not in swipe_removing + assert "transform:translate3d(var(--session-swipe-offset,0),var(--session-reflow-offset,0),0)" in STYLE_CSS + + +def test_session_swipe_actions_use_circular_icon_badges(): + right_start = STYLE_CSS.find(".session-swipe-affordance-right{") + left_start = STYLE_CSS.find(".session-swipe-affordance-left{") + badge_start = STYLE_CSS.find(".session-swipe-badge{") + right = STYLE_CSS[right_start:left_start] + left = STYLE_CSS[left_start:STYLE_CSS.find(".session-item.swiping-right", left_start)] + badge = STYLE_CSS[badge_start:STYLE_CSS.find(".session-swipe-badge svg", badge_start)] + assert "background:transparent" in right + assert "background:transparent" in left + assert "align-items:flex-start" in right + assert "align-items:flex-end" in left + assert "--session-swipe-action-color:var(--warning)" in right + assert "--session-swipe-action-color:var(--error)" in left + assert "width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px))" in badge + assert "height:var(--session-swipe-badge-size,34px)" in badge + assert "border-radius:999px" in badge + assert "background:var(--session-swipe-action-color)" in badge + assert "overflow:hidden" in badge + assert ".session-item.archived .session-swipe-affordance-right{--session-swipe-action-color:var(--success);}" in STYLE_CSS + assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive'))" in SESSIONS_JS + assert "stack.className='session-swipe-action-stack'" in SESSIONS_JS + assert "stack.append(badge,text)" in SESSIONS_JS + stack = STYLE_CSS[STYLE_CSS.find(".session-swipe-action-stack{"):STYLE_CSS.find(".session-swipe-badge{")] + label = STYLE_CSS[STYLE_CSS.find(".session-swipe-label{"):STYLE_CSS.find(".session-item.dragging")] + assert "session-swipe-label" in SESSIONS_JS + assert "width:calc(var(--session-swipe-badge-size,34px) + var(--session-swipe-badge-stretch,0px))" in stack + assert "align-items:center" in stack + assert "min-width:58px" in label + assert "width:100%" in label + assert "text-align:center" in label + assert "color:var(--muted)" in label + assert "font-size:10px" in label + assert "transform:scale(var(--session-swipe-label-scale,1))" in label + assert "transform-origin:top center" in label + assert ".session-swipe-affordance-right .session-swipe-action-stack{transform:translateX(6px);}" in STYLE_CSS + assert ".session-swipe-affordance-left .session-swipe-action-stack{transform:translateX(-6px);}" in STYLE_CSS + + +def test_session_removal_reflows_surviving_rows_smoothly(): + assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS + assert "const _optimisticallyRemovedSessionIds = new Set();" in SESSIONS_JS + assert "const _sessionSwipeReturnOffsets = new Map();" in SESSIONS_JS + capture = _sessions_block("function _captureSessionReflowPositions(){", "function _waitForSessionMotion") + helper = _sessions_block("function _playSessionRowsReflowFromPositions", "function _sessionPrefersReducedMotion") + assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in capture + assert "const delta=oldTop-row.getBoundingClientRect().top;" in helper + assert "const movingRows=[];" in helper + assert "list.getBoundingClientRect();" in helper + assert "row.style.transition='none';" in helper + assert "row.classList.add('session-reflowing')" in helper + assert "requestAnimationFrame(()=>requestAnimationFrame(()=>{" in helper + assert SESSIONS_JS.count("_pendingSessionReflowPositions=reflowPositions;") >= 2 + assert "const reflowBefore=animateRefresh?flipBefore:_pendingSessionReflowPositions;" in SESSIONS_JS + assert "const reflowTimeout=animateRefresh?SESSION_LIST_FLIP_TIMEOUT_MS:SESSION_REFLOW_TIMEOUT_MS;" in SESSIONS_JS + assert "_playSessionRowsReflowFromPositions(reflowBefore,reflowTimeout,_sessionPrefersReducedMotion);" in SESSIONS_JS + assert "async function _archiveSession(session, archived=true, beforeListRender=null){" in SESSIONS_JS + assert "const renderHold=beforeListRender?Promise.resolve().then(beforeListRender):null;" in SESSIONS_JS + assert "const cached=(_allSessions||[]).find(s=>s&&s.session_id===session.session_id);" in SESSIONS_JS + assert "if(cached) cached.archived=archived;" in SESSIONS_JS + archive_start = SESSIONS_JS.find("async function _archiveSession(session, archived=true, beforeListRender=null){") + archive_end = SESSIONS_JS.find("function _openSessionActionMenu", archive_start) + archive_body = SESSIONS_JS[archive_start:archive_end] + toast_idx = archive_body.find("showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored'));") + hold_idx = archive_body.find("if(renderHold) await renderHold;") + cache_render_idx = archive_body.find("renderSessionListFromCache();") + reconcile_idx = archive_body.find("void renderSessionList();") + assert 0 <= toast_idx < hold_idx < cache_render_idx < reconcile_idx + assert "if(renderHold) await renderHold;" in SESSIONS_JS + assert "const serverSessions=_optimisticallyRemovedSessionIds.size" in SESSIONS_JS + assert "? (sessData.sessions||[]).filter(s=>s&&!_optimisticallyRemovedSessionIds.has(s.session_id))" in SESSIONS_JS + assert ".session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;}" in STYLE_CSS + assert "if(_showArchived&&!_sessionPrefersReducedMotion()) _sessionSwipeReturnOffsets.set(session.session_id,'0px');" in SESSIONS_JS + assert "const swipeReturnOffset=_sessionSwipeReturnOffsets.get(s.session_id);" in SESSIONS_JS + assert "el.classList.add('session-swipe-returning');" in SESSIONS_JS + delete_start = SESSIONS_JS.find("async function deleteSession(sid, beforeDelete=null){") + delete_end = SESSIONS_JS.find("// ── Project helpers", delete_start) + assert delete_start >= 0 and delete_end > delete_start + delete_body = SESSIONS_JS[delete_start:delete_end] + hold_start = delete_body.find("const beforeDeleteHold=beforeDelete?Promise.resolve().then(beforeDelete):null;") + delete_request = delete_body.find("const deleteRequest=api('/api/session/delete'") + hold_await = delete_body.find("await beforeDeleteHold;", hold_start) + optimistic_set = delete_body.find("_optimisticallyRemovedSessionIds.add(sid);") + optimistic_remove = delete_body.find("_optimisticallyRemoveSessionFromList(sid);", optimistic_set) + response_await = delete_body.find("const deleteResult=await deleteRequest;") + rollback = delete_body.find("_optimisticallyRemovedSessionIds.delete(sid);") + final_render = delete_body.find("void renderSessionList().finally(()=>_optimisticallyRemovedSessionIds.delete(sid));") + cached_remove = _sessions_block("function _optimisticallyRemoveSessionFromList(sid){", "function _sessionIdFromLocation") + assert "_allSessions=_allSessions.filter(s=>!s||s.session_id!==sid);" in cached_remove + assert "renderSessionListFromCache();" in cached_remove + assert delete_body.find("const reflowPositions=_captureSessionReflowPositions();") < hold_start < delete_request < hold_await < optimistic_set < optimistic_remove < response_await < rollback < final_render + assert "}, error=>({error}));" in delete_body + + +def test_ios_touch_events_drive_session_swipes(): + assert "el.addEventListener('touchstart'" in SESSIONS_JS + assert "el.addEventListener('touchmove'" in SESSIONS_JS + assert "el.addEventListener('touchcancel',_clearPointerDragState" in SESSIONS_JS + assert "el.addEventListener('touchend'" in SESSIONS_JS + assert "const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{" in SESSIONS_JS + assert "{passive:false}" in SESSIONS_JS + assert "if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault();" in SESSIONS_JS + assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 + assert "if(_finishSessionGesture(touch.clientX,touch.clientY,e.target,'touch')) e.stopPropagation();" in SESSIONS_JS + assert "window.PointerEvent" not in SESSIONS_JS + + +def test_touch_session_rows_preserve_vertical_scroll(): + assert ".session-item{padding:8px 8px;" in STYLE_CSS + item_rule = STYLE_CSS[STYLE_CSS.find(".session-item{padding:8px 8px;"):STYLE_CSS.find("}", STYLE_CSS.find(".session-item{padding:8px 8px;"))] + assert "touch-action:pan-y" in item_rule + assert "user-select:none" in item_rule + assert "-webkit-user-select:none" in item_rule + assert "-webkit-touch-callout:none" in item_rule