Merge pull request #3161 from nesquena/release/stage-batch49
Some checks failed
Release & Docker / release (push) Has been cancelled

Release v0.51.167 — Release EM (stage-batch49: iOS-style swipe actions for touch devices #2252)
This commit is contained in:
nesquena-hermes
2026-05-29 20:10:17 -07:00
committed by GitHub
10 changed files with 934 additions and 178 deletions

View File

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

View File

@@ -21,6 +21,7 @@ const LI_PATHS = {
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'braces': '<path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/>',
'trash-2': '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
'archive': '<rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'refresh-cw': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',

View File

@@ -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)<actionThreshold) return false;
if(Math.abs(signedDy)>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 ─────────────────────────────────────────────────────

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,10 @@
"""
Tests for profile-switch UX improvements — spinner indicator + parallelized fetches.
Tests for profile-switch UX improvements.
Two changes:
1. switchToProfile() shows a spinner on the profile chip during the async switch,
with an optimistic name update and error revert.
2. loadWorkspaceList() refreshes in the background and the model catalog is
invalidated for lazy refresh instead of holding the visible switch animation open.
Covered behavior:
- switchToProfile() shows a spinner during the async switch and reverts on error.
- Non-visible refresh work runs after the visible switch completes.
- Session-list refreshes animate rows with row-level FLIP motion.
"""
import re
from pathlib import Path
@@ -165,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

View File

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

View File

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