optimize performance

This commit is contained in:
dobby-d-elf 2026-05-21 07:30:41 -06:00
parent 03266c2644
commit 1d8d0bfeb7
4 changed files with 178 additions and 183 deletions

View file

@ -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;
}

View file

@ -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 */

View file

@ -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)

View file

@ -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)<actionThreshold) return false;" in SESSIONS_JS
assert "const _updateSessionGesture=(clientX,clientY)=>{" 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():