From 587101fb97c32b0a53b8512e545e9ecc41534100 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sun, 17 May 2026 11:55:56 -0600 Subject: [PATCH] Animate session list reflow on removal --- static/sessions.js | 45 +++++++++++++++++++++++++++-- static/style.css | 9 ++++++ tests/test_session_touch_actions.py | 17 +++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 3903acca..6c5cab5d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1303,6 +1303,38 @@ const _lineageReportCache = new Map(); const _lineageReportInflight = new Map(); let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; +let _pendingSessionReflowPositions = null; + +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 _playQueuedSessionReflowAnimation(){ + const before=_pendingSessionReflowPositions; + _pendingSessionReflowPositions=null; + if(!before||!before.size) return; + const reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if(reduce) 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||typeof row.animate!=='function') return; + const anim=row.animate( + [{transform:`translateY(${delta}px)`},{transform:'translateY(0)'}], + {duration:360,easing:'cubic-bezier(.2,.8,.2,1)'} + ); + if(anim&&anim.finished) anim.finished.catch(()=>{}); + }); +} const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; const SESSION_VIRTUAL_THRESHOLD_ROWS = 80; @@ -1609,14 +1641,16 @@ function _playSessionActionMenuEntrance(menu){ async function _archiveSession(session, archived=true){ if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return false; } + const reflowPositions=_captureSessionReflowPositions(); 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; + _pendingSessionReflowPositions=reflowPositions; await renderSessionList(); showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); return true; - }catch(err){showToast(t('session_archive_failed')+err.message);return false;} + }catch(err){_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;} } function _openSessionActionMenu(session, anchorEl){ @@ -2841,6 +2875,7 @@ function renderSessionListFromCache(){ toggleBtn.onclick=(e)=>{e.stopPropagation();toggleSessionSelectMode();}; list.appendChild(toggleBtn); } + _playQueuedSessionReflowAnimation(); // Note: declared after the groups loop but available via function hoisting. function _renderOneSession(s, isPinnedGroup=false){ const el=document.createElement('div'); @@ -3283,6 +3318,10 @@ function renderSessionListFromCache(){ 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; @@ -3575,12 +3614,13 @@ async function deleteSession(sid, beforeDelete=null){ danger:true }); 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})}); _clearHandoffStorageForSession(sid); - }catch(e){setStatus(`Delete failed: ${e.message}`);return false;} + }catch(e){_pendingSessionReflowPositions=null;setStatus(`Delete failed: ${e.message}`);return false;} if(S.session&&S.session.session_id===sid){ S.session=null;S.messages=[];S.entries=[]; localStorage.removeItem('hermes-webui-session'); @@ -3599,6 +3639,7 @@ async function deleteSession(sid, beforeDelete=null){ } } showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted')); + _pendingSessionReflowPositions=reflowPositions; await renderSessionList(); return true; } diff --git a/static/style.css b/static/style.css index a8c269d7..73c10f03 100644 --- a/static/style.css +++ b/static/style.css @@ -492,6 +492,15 @@ } .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} .session-item.swipe-committed{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 */ .session-item.dragging:hover{background:transparent;color:var(--muted);} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 1843adec..4b88685d 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -84,6 +84,8 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS assert "const _completeSessionSwipePaint=(signedDx)=>{" 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 @@ -110,6 +112,8 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert ".session-item.active.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 @@ -119,6 +123,19 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): 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 "window.matchMedia('(prefers-reduced-motion: reduce)').matches" in SESSIONS_JS + assert "const delta=oldTop-row.getBoundingClientRect().top;" in SESSIONS_JS + assert "{duration:360,easing:'cubic-bezier(.2,.8,.2,1)'}" in SESSIONS_JS + assert SESSIONS_JS.count("const reflowPositions=_captureSessionReflowPositions();") >= 2 + assert SESSIONS_JS.count("_pendingSessionReflowPositions=reflowPositions;") >= 2 + assert "_playQueuedSessionReflowAnimation();" in SESSIONS_JS + + def test_ios_touch_events_drive_session_swipes(): assert "el.addEventListener('touchstart'" in SESSIONS_JS assert "el.addEventListener('touchmove'" in SESSIONS_JS