Improve mobile session list actions
This commit is contained in:
parent
5677b12a88
commit
ac66c9093f
3 changed files with 237 additions and 21 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
tests/test_session_touch_actions.py
Normal file
78
tests/test_session_touch_actions.py
Normal 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
|
||||
Loading…
Reference in a new issue