optimize performance
This commit is contained in:
parent
03266c2644
commit
1d8d0bfeb7
4 changed files with 178 additions and 183 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue