From 1d8d0bfeb734bd6e1eca29a0f27b08066b95e914 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 07:30:41 -0600 Subject: [PATCH] optimize performance --- static/sessions.js | 149 +++++++++++++---------- static/style.css | 9 +- tests/test_profile_switch_ux.py | 25 ++-- tests/test_session_touch_actions.py | 178 +++++++++++++--------------- 4 files changed, 178 insertions(+), 183 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index eb59da72..e7a2479a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -107,6 +107,10 @@ let _sessionListLastScrollAt = 0; let _pendingSessionListPayload = null; let _pendingSessionListApplyTimer = 0; const SESSION_LIST_INTERACTION_IDLE_MS = 700; +const SESSION_SWIPE_DURATION_MS = 420; +const SESSION_SWIPE_REFLOW_LEAD_MS = 180; +const SESSION_REFLOW_TIMEOUT_MS = 420; +const SESSION_LIST_FLIP_TIMEOUT_MS = 460; function _formatSessionModelWithGateway(s){ if(!s||!s.model)return''; @@ -1454,6 +1458,7 @@ const _lineageReportInflight = new Map(); let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; let _pendingSessionReflowPositions = null; +const _optimisticallyRemovedSessionIds = new Set(); function _captureSessionReflowPositions(){ const list=$('sessionList'); @@ -1465,23 +1470,31 @@ function _captureSessionReflowPositions(){ return positions; } -function _playQueuedSessionReflowAnimation(){ - const before=_pendingSessionReflowPositions; - _pendingSessionReflowPositions=null; +function _waitForSessionMotion(ms){ + return new Promise(resolve=>setTimeout(resolve,ms)); +} + +function _playSessionRowsReflowFromPositions(before, timeoutMs, prefersReducedMotion){ if(!before||!before.size) return; - const reduce=_sessionPrefersReducedMotion(); - if(reduce) 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'); - row.getBoundingClientRect(); - row.style.setProperty('--session-reflow-offset','0px'); + }); + list.getBoundingClientRect(); + movingRows.forEach(({row})=>{ let reflowCleared=false; const clearReflow=()=>{ if(reflowCleared) return; @@ -1494,10 +1507,20 @@ function _playQueuedSessionReflowAnimation(){ if(event.propertyName==='transform') clearReflow(); }; row.addEventListener('transitionend',onReflowEnd); - setTimeout(clearReflow,420); + row.style.removeProperty('transition'); + requestAnimationFrame(()=>requestAnimationFrame(()=>{ + if(!reflowCleared) row.style.setProperty('--session-reflow-offset','0px'); + })); + setTimeout(clearReflow,timeoutMs); }); } +function _playQueuedSessionReflowAnimation(){ + const before=_pendingSessionReflowPositions; + _pendingSessionReflowPositions=null; + _playSessionRowsReflowFromPositions(before,SESSION_REFLOW_TIMEOUT_MS,_sessionPrefersReducedMotion); +} + function _discardQueuedSessionReflowAnimation(){ _pendingSessionReflowPositions=null; } @@ -1812,18 +1835,20 @@ function _playSessionActionMenuEntrance(menu){ menu.classList.add('open-animated'); } -async function _archiveSession(session, archived=true){ +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; if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived; + if(renderHold) await renderHold; _pendingSessionReflowPositions=reflowPositions; await renderSessionList(); showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); return true; - }catch(err){_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;} + }catch(err){if(renderHold) await renderHold.catch(()=>{});_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;} } function _openSessionActionMenu(session, anchorEl){ @@ -2003,46 +2028,13 @@ 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); - }); + _playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion); } function _isOptimisticFirstTurnSessionRow(s){ @@ -2135,8 +2127,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); @@ -3039,7 +3034,7 @@ function renderSessionListFromCache(){ _sessionListRefreshAnimationPending=false; const enterAllAnimatedRows=animateRefresh&&_sessionListEnterAllAnimationPending; _sessionListEnterAllAnimationPending=false; - const flipBefore=animateRefresh?_captureSessionListFlipPositions():null; + const flipBefore=animateRefresh?_captureSessionReflowPositions():null; const listScrollTopBeforeRender=list.scrollTop||0; list.innerHTML=''; // Batch select bar (when in select mode) @@ -3680,7 +3675,8 @@ function renderSessionListFromCache(){ const _archiveSwipeActionThreshold=128; const _deleteSwipeActionThreshold=128; const _swipeCancelRatio=0.75; - const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:420; + const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS; + const _committedSwipeReflowDelay=Math.max(0,_committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS); const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} if(!_longPressMenuOpened) el.classList.remove('long-pressing'); @@ -3792,16 +3788,22 @@ function renderSessionListFromCache(){ _tapTimer=null; _lastTapTime=0; if(signedDx>0){ - _completeSessionSwipePaint(signedDx); - setTimeout(async()=>{ - const archived=await _archiveSession(s,!s.archived); - if(!archived) _settleSessionSwipePaint(); - },_committedSwipeDuration); + if(s.archived){ + _settleSessionSwipePaint(); + _archiveSession(s,false,()=>_waitForSessionMotion(_committedSwipeDuration)).then((restored)=>{ + if(!restored) _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 new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration)); + await _waitForSessionMotion(_committedSwipeReflowDelay); }).then((deleted)=>{ if(!deleted) _settleSessionSwipePaint(); }); @@ -4056,12 +4058,34 @@ async function deleteSession(sid, beforeDelete=null){ }); if(!ok)return false; const reflowPositions=_captureSessionReflowPositions(); - if(beforeDelete) await beforeDelete(); - let response=null; - try{ - response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); + 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){_pendingSessionReflowPositions=null;setStatus(`Delete failed: ${e.message}`);return false;} + return {response}; + }, error=>({error})); + if(beforeDeleteHold){ + await beforeDeleteHold; + _optimisticallyRemovedSessionIds.add(sid); + _allSessions=(_allSessions||[]).filter(item=>item&&item.session_id!==sid); + _pendingSessionReflowPositions=reflowPositions; + renderSessionListFromCache(); + 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(S.session&&S.session.session_id===sid){ S.session=null;S.messages=[];S.entries=[]; localStorage.removeItem('hermes-webui-session'); @@ -4080,8 +4104,11 @@ async function deleteSession(sid, beforeDelete=null){ } } showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted')); - _pendingSessionReflowPositions=reflowPositions; - await renderSessionList(); + if(optimisticRendered) void renderSessionList().finally(()=>_optimisticallyRemovedSessionIds.delete(sid)); + else{ + _pendingSessionReflowPositions=reflowPositions; + await renderSessionList(); + } return true; } diff --git a/static/style.css b/static/style.css index f92dda16..6168f4df 100644 --- a/static/style.css +++ b/static/style.css @@ -727,15 +727,10 @@ } .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} .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{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} + .session-item.swipe-committed, + .session-item.swipe-removing{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} .session-item.swipe-removing{ - min-height:0!important; - height:0!important; - margin-bottom:0; - padding-top:0; - padding-bottom:0; overflow:hidden; - transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease,height .36s cubic-bezier(.2,.8,.2,1),min-height .36s cubic-bezier(.2,.8,.2,1),margin-bottom .36s cubic-bezier(.2,.8,.2,1),padding-top .36s cubic-bezier(.2,.8,.2,1),padding-bottom .36s cubic-bezier(.2,.8,.2,1); } .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 */ diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index f7a9dbe1..79663922 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,22 +164,12 @@ 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 + assert "_playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion);" in fn def test_profile_refresh_flips_new_rows(self): assert "session-list-flip-enter" in self.JS @@ -188,7 +177,7 @@ class TestProfileSessionListFlip: 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) diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 81e929b0..e901bb62 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -6,6 +6,18 @@ 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")] @@ -69,131 +81,103 @@ def test_open_session_menu_consumes_next_row_activation(): assert "if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation();" in SESSIONS_JS[pointerup_idx:] -def test_session_swipes_archive_right_and_delete_left(): +def test_session_swipes_route_archive_restore_and_delete(): assert "_gesturePointerType!=='mouse'" in SESSIONS_JS assert "_swipeTracking=true" in SESSIONS_JS - assert "const _trackHorizontalSwipe=(dx,dy)=>{" in SESSIONS_JS assert "_archiveSwipeActionThreshold=128" in SESSIONS_JS assert "_deleteSwipeActionThreshold=128" in SESSIONS_JS - assert "_committedSwipeDuration=_sessionPrefersReducedMotion()?0:420" in SESSIONS_JS - assert "const _handleSessionSwipe=(signedDx,signedDy)=>{" in SESSIONS_JS + assert "const SESSION_SWIPE_DURATION_MS = 420;" in SESSIONS_JS + assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 180;" in SESSIONS_JS + assert "const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS;" in SESSIONS_JS + assert "const _committedSwipeReflowDelay=Math.max(0,_committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS);" in SESSIONS_JS + swipe_block = _sessions_block("const _handleSessionSwipe=(signedDx,signedDy)=>{", "const _commitSessionSwipe=()=>{") assert "const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold;" in SESSIONS_JS assert "if(Math.abs(signedDx){" in SESSIONS_JS assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS - assert "_updateSessionGesture(e.clientX,e.clientY);" 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 - assert "if(signedDx>0){" in SESSIONS_JS - assert "_archiveSession(s,!s.archived)" in SESSIONS_JS - assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS - delete_branch_start = SESSIONS_JS.find("}else if(_canSwipeDeleteSession()){") - delete_branch_end = SESSIONS_JS.find("}else if(typeof showToast", delete_branch_start) - assert delete_branch_start >= 0 and delete_branch_end > delete_branch_start - delete_branch = SESSIONS_JS[delete_branch_start:delete_branch_end] - delete_call_idx = delete_branch.find("deleteSession(s.session_id,async()=>{") - delete_complete_idx = delete_branch.find("_completeSessionSwipePaint(signedDx);") - assert delete_call_idx > 0 and delete_complete_idx > delete_call_idx - assert "const completedAt=Date.now();" not in delete_branch + 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 + 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 "_gestureState='dragging';" in SESSIONS_JS - assert "const _promoteSessionDrag=(dx,dy)=>{" in SESSIONS_JS assert "const _commitSessionSwipe=()=>{" in SESSIONS_JS - assert "_commitSessionSwipe();" in SESSIONS_JS - assert "const wasDragging=_gestureState==='dragging'||_swipeTracking;" in SESSIONS_JS assert "if(_gestureState==='committed'){" in SESSIONS_JS assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 - assert "el.onpointercancel=(e)=>{" in SESSIONS_JS - assert "if(e.pointerType==='touch') return;" in SESSIONS_JS assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS -def test_session_swipes_show_visual_feedback_and_touch_load_clears(): - assert "const _paintSessionSwipe=(signedDx)=>{" in SESSIONS_JS - assert "const rawOffset=signedDx*.55" in SESSIONS_JS - assert "const revealedOffset=Math.max(-72,Math.min(72,rawOffset))" in SESSIONS_JS - assert "const overshoot=Math.max(0,Math.abs(rawOffset)-72)" in SESSIONS_JS - assert "Math.sqrt(overshoot)*5" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-offset',offset+'px')" in SESSIONS_JS - assert "const reveal=Math.min(132,Math.max(36,Math.abs(rawOffset)+24));" in SESSIONS_JS - assert "const iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130);" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-reveal',reveal+'px')" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-icon-scale',iconScale)" in SESSIONS_JS - assert "const progress=Math.min(1,Math.abs(revealedOffset)/72)" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5))" in SESSIONS_JS - assert "const _clearSessionSwipePaint=()=>{" in SESSIONS_JS - assert "el.style.removeProperty('--session-swipe-reveal');" in SESSIONS_JS - assert "el.style.removeProperty('--session-swipe-icon-scale');" in SESSIONS_JS - assert "el.style.removeProperty('height');" in SESSIONS_JS - assert "el.style.removeProperty('min-height');" in SESSIONS_JS - assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in SESSIONS_JS - assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS - assert "const _completeSessionSwipePaint=(signedDx)=>{" in SESSIONS_JS - assert "el.classList.remove('dragging');" in SESSIONS_JS - assert "el.classList.add('swipe-committed')" in SESSIONS_JS - assert "el.style.height=rect.height+'px'" in SESSIONS_JS - assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-progress','0')" in SESSIONS_JS - assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS - assert "const archived=await _archiveSession(s,!s.archived);" in SESSIONS_JS - assert "if(!archived) _settleSessionSwipePaint();" in SESSIONS_JS - assert "await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration));" in SESSIONS_JS - assert "async function deleteSession(sid, beforeDelete=null){" in SESSIONS_JS - assert "if(beforeDelete) await beforeDelete();" 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 "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 ".session-item.swiping-right" in STYLE_CSS - assert ".session-item.swiping-left" in STYLE_CSS - assert "const _makeSessionSwipeAffordance=(side,icon,label)=>{" in SESSIONS_JS - assert "affordance.setAttribute('aria-hidden','true');" in SESSIONS_JS - assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive'))" in SESSIONS_JS - assert "_makeSessionSwipeAffordance('left','trash-2'" in SESSIONS_JS - assert ".session-swipe-affordance{" in STYLE_CSS - assert "opacity:var(--session-swipe-progress,0)" in STYLE_CSS - assert "width:var(--session-swipe-reveal,0px)" in STYLE_CSS - assert ".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);}" in STYLE_CSS - assert ".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);}" in STYLE_CSS - assert "background:var(--warning)" in STYLE_CSS - assert ".session-item.archived .session-swipe-affordance-right{background:var(--success);}" in STYLE_CSS - assert ".session-item.archived.dragging.swiping-right" in STYLE_CSS - assert ".session-item.active.archived.swiping-right{background:color-mix(in srgb,var(--success) 20%,var(--accent-bg));}" in STYLE_CSS - assert "background:var(--error)" in STYLE_CSS - assert ".session-item.swiping-right .session-swipe-affordance-right" in STYLE_CSS - assert ".session-item.swiping-left .session-swipe-affordance-left" in STYLE_CSS + assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear + assert ".session-item.swipe-committed,\n .session-item.swipe-removing{transition:" in STYLE_CSS assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" in STYLE_CSS - assert ".session-swipe-badge{" in STYLE_CSS - assert "transform:scaleX(var(--session-swipe-icon-scale,1))" in STYLE_CSS - assert ".session-swipe-label{" in STYLE_CSS - assert "transform .5s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS - assert ".session-item.dragging.swiping-right" in STYLE_CSS - assert ".session-item.dragging.swiping-left" in STYLE_CSS - assert ".session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;}" in STYLE_CSS - assert ".session-item.swipe-committed" in STYLE_CSS - assert ".session-item.swipe-removing{" in STYLE_CSS - assert "height .36s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS - assert "transform .42s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS - assert ".session-item.swipe-committed .session-swipe-affordance{transition:opacity .18s ease,transform .18s ease;}" in STYLE_CSS - assert ".session-item.long-pressing" in STYLE_CSS - assert "@keyframes session-long-press" 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:translateX(var(--session-swipe-offset,0))" in STYLE_CSS - assert "finally{" in SESSIONS_JS - assert "el.classList.remove('loading');" in SESSIONS_JS def test_session_removal_reflows_surviving_rows_smoothly(): assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS - assert "function _captureSessionReflowPositions(){" in SESSIONS_JS - assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in SESSIONS_JS - assert "function _playQueuedSessionReflowAnimation(){" in SESSIONS_JS - assert "function _sessionPrefersReducedMotion(){" in SESSIONS_JS - assert "const delta=oldTop-row.getBoundingClientRect().top;" in SESSIONS_JS - assert "row.style.setProperty('--session-reflow-offset',delta+'px')" in SESSIONS_JS - assert "row.classList.add('session-reflowing')" in SESSIONS_JS - assert "row.style.setProperty('--session-reflow-offset','0px')" in SESSIONS_JS - assert "const reflowPositions=_captureSessionReflowPositions();" in SESSIONS_JS + assert "const _optimisticallyRemovedSessionIds = new Set();" in SESSIONS_JS + capture = _sessions_block("function _captureSessionReflowPositions(){", "function _waitForSessionMotion") + helper = _sessions_block("function _playSessionRowsReflowFromPositions", "function _playQueuedSessionReflowAnimation") + 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 helper.count("getBoundingClientRect()") == 2 + 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 "_playSessionRowsReflowFromPositions(before,SESSION_REFLOW_TIMEOUT_MS,_sessionPrefersReducedMotion);" in SESSIONS_JS + assert "_playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion);" 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 "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 "_playQueuedSessionReflowAnimation();" 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 + 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_filter = delete_body.find("_allSessions=(_allSessions||[]).filter") + optimistic_render = delete_body.find("renderSessionListFromCache();", optimistic_filter) + 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));") + assert delete_body.find("const reflowPositions=_captureSessionReflowPositions();") < hold_start < delete_request < hold_await < optimistic_set < optimistic_filter < optimistic_render < response_await < rollback < final_render + assert "}, error=>({error}));" in delete_body def test_ios_touch_events_drive_session_swipes():