Merge pull request #3161 from nesquena/release/stage-batch49
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
@@ -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"/>',
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
143
static/style.css
143
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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
276
tests/test_session_touch_actions.py
Normal file
276
tests/test_session_touch_actions.py
Normal 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
|
||||
Reference in New Issue
Block a user