Improve mobile session list actions

This commit is contained in:
dobby-d-elf 2026-05-14 08:35:30 -06:00
parent 5677b12a88
commit ac66c9093f
3 changed files with 237 additions and 21 deletions

View file

@ -1508,7 +1508,9 @@ 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');
_sessionActionAnchor = null;
@ -1573,6 +1575,37 @@ function _appendSessionDuplicateAction(menu, session){
));
}
function _playSessionActionMenuEntrance(menu){
if(!menu) return;
const reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
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:500, 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){
if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return; }
try{
const response=await api('/api/session/archive',{method:'POST',body:JSON.stringify({session_id:session.session_id,archived})});
session.archived=archived;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived;
await renderSessionList();
showToast(archived?_sessionArchiveToast(response,session):t('session_restored'));
}catch(err){showToast(t('session_archive_failed')+err.message);}
}
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){
@ -1584,7 +1617,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
@ -1643,13 +1676,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})});
session.archived=!session.archived;
if(S.session&&S.session.session_id===session.session_id) S.session.archived=session.archived;
await 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){
@ -1695,10 +1722,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=>{
@ -3133,8 +3161,63 @@ function renderSessionListFromCache(){
let _pointerActive=false;
let _isDragging=false;
let _clearDragTimer=null;
let _longPressTimer=null;
let _longPressMenuOpened=false;
let _swipeHandled=false;
const _longPressDelay=560;
const _swipeActionThreshold=96;
const _swipeCancelRatio=0.75;
const _clearLongPressTimer=()=>{
if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;}
};
const _beginSessionTouchGesture=(clientX,clientY)=>{
_pointerActive=true;
_pointerDownX=clientX;
_pointerDownY=clientY;
_isDragging=false;
_longPressMenuOpened=false;
_swipeHandled=false;
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
el.classList.remove('dragging');
};
const _scheduleSessionLongPressMenu=()=>{
_clearLongPressTimer();
_longPressTimer=setTimeout(()=>{
if(!_pointerActive||_isDragging||_renamingSid||_sessionSelectMode||readOnly) return;
_longPressMenuOpened=true;
clearTimeout(_tapTimer);
_tapTimer=null;
_lastTapTime=0;
_openSessionActionMenu(s, el);
},_longPressDelay);
};
const _isSessionSwipeTarget=()=>{
return !readOnly&&!_renamingSid&&!_sessionSelectMode;
};
const _canSwipeDeleteSession=()=>{
return _isSessionSwipeTarget()&&!_isMessagingSession(s)&&!_isCliSession(s);
};
const _handleSessionSwipe=(signedDx,signedDy)=>{
if(_swipeHandled||!_isSessionSwipeTarget()) return false;
if(Math.abs(signedDx)<_swipeActionThreshold) return false;
if(Math.abs(signedDy)>Math.abs(signedDx)*_swipeCancelRatio) return false;
_swipeHandled=true;
_clearLongPressTimer();
clearTimeout(_tapTimer);
_tapTimer=null;
_lastTapTime=0;
if(signedDx>0){
_archiveSession(s,true);
}else if(_canSwipeDeleteSession()){
deleteSession(s.session_id);
}else if(typeof showToast==='function'){
showToast('Imported sessions cannot be deleted here.',3000);
}
return true;
};
const _clearPointerDragState=()=>{
_pointerActive=false;
_clearLongPressTimer();
if(_isDragging){
_isDragging=false;
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
@ -3143,35 +3226,43 @@ function renderSessionListFromCache(){
};
el.onpointerdown=(e)=>{
if(e.pointerType==='mouse' && e.button!==0) return;
_pointerActive=true;
_pointerDownX=e.clientX;
_pointerDownY=e.clientY;
_isDragging=false;
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
el.classList.remove('dragging');
_beginSessionTouchGesture(e.clientX,e.clientY);
if(e.pointerType==='touch'||e.pointerType==='pen'){
_scheduleSessionLongPressMenu();
}
};
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){
if(!_isDragging&&(dx>5||dy>5)){
if(dy>8||dx>10) _clearLongPressTimer();
_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;}
}
const signedDx=e.clientX-_pointerDownX;
const signedDy=e.clientY-_pointerDownY;
_handleSessionSwipe(signedDx,signedDy);
};
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;
_clearLongPressTimer();
if(_renamingSid) return;
if(actions&&actions.contains(e.target)) return;
if(_longPressMenuOpened||_swipeHandled){e.stopPropagation();return;}
if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){
closeSessionActionMenu();
e.stopPropagation();
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
@ -3215,6 +3306,42 @@ function renderSessionListFromCache(){
if (_loadingSessionId && _loadingSessionId !== s.session_id) return;
startRename();
};
el.oncontextmenu=(e)=>{
if(e.pointerType==='touch'||e.pointerType==='pen'){
e.preventDefault();
}
};
el.addEventListener('touchstart',(e)=>{
const touch=e.changedTouches&&e.changedTouches[0];
if(!touch) return;
_beginSessionTouchGesture(touch.clientX,touch.clientY);
_scheduleSessionLongPressMenu();
},{passive:true});
el.addEventListener('touchmove',(e)=>{
if(!_pointerActive) return;
const touch=e.changedTouches&&e.changedTouches[0];
if(!touch) return;
const signedDx=touch.clientX-_pointerDownX;
const signedDy=touch.clientY-_pointerDownY;
const dx=Math.abs(signedDx);
const dy=Math.abs(signedDy);
if(dx>10||dy>8) _clearLongPressTimer();
if(!_isDragging&&(dx>5||dy>5)){
_isDragging=true;
el.classList.add('dragging');
if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;}
}
if(dx>16&&dx>dy*1.2) e.preventDefault();
_handleSessionSwipe(signedDx,signedDy);
},{passive:false});
el.addEventListener('touchend',()=>{
_pointerActive=false;
_clearLongPressTimer();
if(_isDragging){
_isDragging=false;
_clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50);
}
},{passive:true});
return el;
}
}

View file

@ -353,7 +353,7 @@
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;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}
.session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;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;}
.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);}
/* Suppress hover highlight during drag to avoid visual noise mid-scroll */
@ -422,8 +422,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 .5s 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;}
@ -433,8 +433,12 @@
.session-action-opt.is-active{background:var(--accent-bg);}
.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;}
}
/* 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 newflash{0%{background:var(--accent-bg-strong);color:var(--accent);}100%{background:transparent;color:var(--muted);}}
@keyframes spin{to{transform:rotate(360deg);}}
.session-item.new-flash{animation:newflash 1.4s ease-out forwards;}
@ -1538,6 +1542,13 @@
.suggestion{font-size:12px;padding:10px 12px;}
}
@media (hover:none) and (pointer:coarse){
.session-actions{display:none;}
.session-item{padding-right:12px;}
.session-item.streaming,.session-item.unread{padding-right:40px;}
.session-item:focus-within,.session-item.menu-open{padding-right:12px;}
}
@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

@ -0,0 +1,78 @@
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 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_has_subtle_open_animation():
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 "transform-origin:top right" in session_menu
assert "function _playSessionActionMenuEntrance(menu){" in SESSIONS_JS
assert "typeof menu.animate==='function'" in SESSIONS_JS
assert "{opacity:0, transform:'translate3d(0,-4px,0) scale(.985)'}" in SESSIONS_JS
assert "{duration:500, easing:'cubic-bezier(.2,.8,.2,1)'}" in SESSIONS_JS
assert "menu.classList.add('open-animated')" in SESSIONS_JS
assert ".session-action-menu.open-animated{animation:session-menu-in .5s cubic-bezier(.2,.8,.2,1);}" in STYLE_CSS
assert "@keyframes session-menu-in" in STYLE_CSS
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 "_longPressDelay=560" 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 _beginSessionTouchGesture=(clientX,clientY)=>{" 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:12px;}" 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:12px;}" in mobile_touch
def test_open_session_menu_consumes_next_row_activation():
assert "if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){" in SESSIONS_JS
assert "closeSessionActionMenu();" in SESSIONS_JS
assert "e.stopPropagation();" in SESSIONS_JS
pointerup_idx = SESSIONS_JS.find("el.onpointerup=(e)=>{")
dismiss_idx = SESSIONS_JS.find("if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){", pointerup_idx)
load_idx = SESSIONS_JS.find("await loadSession(s.session_id)", pointerup_idx)
assert pointerup_idx > 0 and load_idx > pointerup_idx
assert dismiss_idx > pointerup_idx and dismiss_idx < load_idx
def test_session_swipes_archive_right_and_delete_left():
assert "_swipeActionThreshold=96" in SESSIONS_JS
assert "const _handleSessionSwipe=(signedDx,signedDy)=>{" in SESSIONS_JS
assert "if(signedDx>0){" in SESSIONS_JS
assert "_archiveSession(s,true)" in SESSIONS_JS
assert "deleteSession(s.session_id)" in SESSIONS_JS
assert "if(!_isDragging&&(dx>5||dy>5))" in SESSIONS_JS
assert "_handleSessionSwipe(signedDx,signedDy)" in SESSIONS_JS
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('touchend'" in SESSIONS_JS
assert "{passive:false}" in SESSIONS_JS
assert "e.preventDefault()" 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