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