From ac66c9093ff6bfbcd4d2ade29cc18193a779e194 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 14 May 2026 08:35:30 -0600 Subject: [PATCH] Improve mobile session list actions --- static/sessions.js | 163 +++++++++++++++++++++++++--- static/style.css | 17 ++- tests/test_session_touch_actions.py | 78 +++++++++++++ 3 files changed, 237 insertions(+), 21 deletions(-) create mode 100644 tests/test_session_touch_actions.py diff --git a/static/sessions.js b/static/sessions.js index 67356dc2..36bd1fe5 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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; } } diff --git a/static/style.css b/static/style.css index 2874289d..f47e581d 100644 --- a/static/style.css +++ b/static/style.css @@ -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 diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py new file mode 100644 index 00000000..0b483866 --- /dev/null +++ b/tests/test_session_touch_actions.py @@ -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