From ac66c9093ff6bfbcd4d2ade29cc18193a779e194 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 14 May 2026 08:35:30 -0600 Subject: [PATCH 001/349] 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 67356dc2f0..36bd1fe5b0 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 2874289db8..f47e581dfd 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 0000000000..0b48386621 --- /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 From 892a946851cd9e6379d179d6215c8fe10a38a3a1 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 14 May 2026 08:54:43 -0600 Subject: [PATCH 002/349] fix tests --- static/sessions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/sessions.js b/static/sessions.js index 36bd1fe5b0..f1d04f8e81 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1602,7 +1602,7 @@ async function _archiveSession(session, archived=true){ 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')); + showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); }catch(err){showToast(t('session_archive_failed')+err.message);} } From 5b2c8c11f1a09ce953013e6c25042e918a757e31 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Fri, 15 May 2026 08:38:42 -0600 Subject: [PATCH 003/349] fix: avoid sticky touch hover in session list --- static/sessions.js | 2 ++ static/style.css | 10 +++++----- tests/test_session_touch_actions.py | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 6b41154c02..867ac33f61 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3288,6 +3288,7 @@ function renderSessionListFromCache(){ clearTimeout(_tapTimer); _tapTimer=null; _lastTapTime=0; + el.classList.remove('loading'); startRename(); return; } @@ -3297,6 +3298,7 @@ function renderSessionListFromCache(){ // accidental navigation during scroll-drag lifts. clearTimeout(_tapTimer); const delay=e.pointerType==='mouse'?0:300; + if(e.pointerType!=='mouse') el.classList.add('loading'); _tapTimer=setTimeout(async()=>{ _tapTimer=null; _lastTapTime=0; diff --git a/static/style.css b/static/style.css index 89e5b9ce38..df4f5376ec 100644 --- a/static/style.css +++ b/static/style.css @@ -206,7 +206,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);} @@ -354,14 +353,15 @@ 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: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);} + @media (hover:hover){.session-item:hover{background:var(--hover-bg);color:var(--text);}} + .session-item.loading{background:var(--hover-bg);color:var(--text);} /* Suppress hover highlight during drag to avoid visual noise mid-scroll */ .session-item.dragging:hover{background:transparent;color:var(--muted);} /* 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.streaming .session-title{color:var(--accent);} .session-item.streaming .session-title-row{color:var(--text);} @@ -1551,9 +1551,9 @@ @media (hover:none) and (pointer:coarse){ .session-actions{display:none;} - .session-item{padding-right:12px;} + .session-item{padding-right:6px;} .session-item.streaming,.session-item.unread{padding-right:40px;} - .session-item:focus-within,.session-item.menu-open{padding-right:12px;} + .session-item:focus-within,.session-item.menu-open{padding-right:6px;} } @media (max-width: 340px){ diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 0b48386621..c4f917aa6d 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -35,9 +35,9 @@ def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): 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{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:12px;}" 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(): From 4d7fa305fa160a1ecbffae45e4af5f243c5642f6 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sat, 16 May 2026 13:45:15 -0600 Subject: [PATCH 004/349] Polish mobile session swipe feedback --- static/sessions.js | 126 ++++++++++++++++++++++------ static/style.css | 52 +++++++++++- tests/test_session_touch_actions.py | 67 +++++++++++++-- 3 files changed, 210 insertions(+), 35 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 1ad782146a..ba496d0a64 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1524,7 +1524,7 @@ function closeSessionActionMenu(){ _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; @@ -1598,7 +1598,7 @@ function _playSessionActionMenuEntrance(menu){ {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)'} + {duration:450, easing:'cubic-bezier(.2,.8,.2,1)'} ); if(anim&&anim.finished) anim.finished.catch(()=>{}); return; @@ -1608,14 +1608,15 @@ function _playSessionActionMenuEntrance(menu){ } async function _archiveSession(session, archived=true){ - if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return; } + if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return false; } 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(session.archived?_sessionArchiveToast(response,session):t('session_restored')); - }catch(err){showToast(t('session_archive_failed')+err.message);} + return true; + }catch(err){showToast(t('session_archive_failed')+err.message);return false;} } function _openSessionActionMenu(session, anchorEl){ @@ -3191,24 +3192,35 @@ function renderSessionListFromCache(){ let _longPressTimer=null; let _longPressMenuOpened=false; let _swipeHandled=false; - const _longPressDelay=560; - const _swipeActionThreshold=96; + let _swipeTracking=false; + let _pointerX=0; + let _pointerY=0; + let _gesturePointerType=''; + const _longPressDelay=400; + const _swipeActionThreshold=144; const _swipeCancelRatio=0.75; + const _committedSwipeDuration=420; const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} + if(!_longPressMenuOpened) el.classList.remove('long-pressing'); }; - const _beginSessionTouchGesture=(clientX,clientY)=>{ + const _beginSessionGesture=(clientX,clientY)=>{ _pointerActive=true; + _gesturePointerType=''; _pointerDownX=clientX; _pointerDownY=clientY; + _pointerX=clientX; + _pointerY=clientY; _isDragging=false; + _swipeTracking=false; _longPressMenuOpened=false; _swipeHandled=false; if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} - el.classList.remove('dragging'); + el.classList.remove('dragging','swipe-committed'); }; const _scheduleSessionLongPressMenu=()=>{ _clearLongPressTimer(); + el.classList.add('long-pressing'); _longPressTimer=setTimeout(()=>{ if(!_pointerActive||_isDragging||_renamingSid||_sessionSelectMode||readOnly) return; _longPressMenuOpened=true; @@ -3219,11 +3231,37 @@ function renderSessionListFromCache(){ },_longPressDelay); }; const _isSessionSwipeTarget=()=>{ - return !readOnly&&!_renamingSid&&!_sessionSelectMode; + return _gesturePointerType!=='mouse'&&!readOnly&&!_renamingSid&&!_sessionSelectMode; + }; + const _trackHorizontalSwipe=(dx,dy)=>{ + if(dx>16&&dx>dy*1.2) _swipeTracking=true; }; const _canSwipeDeleteSession=()=>{ return _isSessionSwipeTarget()&&!_isMessagingSession(s)&&!_isCliSession(s); }; + const _paintSessionSwipe=(signedDx)=>{ + const offset=Math.max(-72,Math.min(72,signedDx*.55)); + const progress=Math.min(1,Math.abs(offset)/72); + el.style.setProperty('--session-swipe-offset',offset+'px'); + el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5)); + 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-progress'); + el.classList.remove('swiping-right','swiping-left','swipe-committed'); + }; + 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 _handleSessionSwipe=(signedDx,signedDy)=>{ if(_swipeHandled||!_isSessionSwipeTarget()) return false; if(Math.abs(signedDx)<_swipeActionThreshold) return false; @@ -3234,26 +3272,42 @@ function renderSessionListFromCache(){ _tapTimer=null; _lastTapTime=0; if(signedDx>0){ - _archiveSession(s,true); + _completeSessionSwipePaint(signedDx); + setTimeout(async()=>{ + const archived=await _archiveSession(s,!s.archived); + if(!archived) _settleSessionSwipePaint(); + },_committedSwipeDuration); }else if(_canSwipeDeleteSession()){ - deleteSession(s.session_id); + el.classList.remove('dragging'); + deleteSession(s.session_id,async()=>{ + _completeSessionSwipePaint(signedDx); + await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration)); + }).then((deleted)=>{ + if(!deleted) _settleSessionSwipePaint(); + }); }else if(typeof showToast==='function'){ showToast('Imported sessions cannot be deleted here.',3000); + _swipeHandled=false; + _settleSessionSwipePaint(); } return true; }; + const _commitSessionSwipe=()=>{ + return _handleSessionSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY); + }; const _clearPointerDragState=()=>{ _pointerActive=false; _clearLongPressTimer(); if(_isDragging){ _isDragging=false; if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} - _clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50); + _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); } }; el.onpointerdown=(e)=>{ if(e.pointerType==='mouse' && e.button!==0) return; - _beginSessionTouchGesture(e.clientX,e.clientY); + _beginSessionGesture(e.clientX,e.clientY); + _gesturePointerType=e.pointerType||''; if(e.pointerType==='touch'||e.pointerType==='pen'){ _scheduleSessionLongPressMenu(); } @@ -3263,6 +3317,8 @@ function renderSessionListFromCache(){ // 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; + _pointerX=e.clientX; + _pointerY=e.clientY; const dx=Math.abs(e.clientX-_pointerDownX); const dy=Math.abs(e.clientY-_pointerDownY); if(!_isDragging&&(dx>5||dy>5)){ @@ -3274,7 +3330,8 @@ function renderSessionListFromCache(){ } const signedDx=e.clientX-_pointerDownX; const signedDy=e.clientY-_pointerDownY; - _handleSessionSwipe(signedDx,signedDy); + _trackHorizontalSwipe(Math.abs(signedDx),Math.abs(signedDy)); + if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx); }; el.onpointercancel=_clearPointerDragState; el.onpointerleave=()=>{ if(_pointerActive) _clearPointerDragState(); }; @@ -3284,6 +3341,9 @@ function renderSessionListFromCache(){ _clearLongPressTimer(); if(_renamingSid) return; if(actions&&actions.contains(e.target)) return; + _pointerX=e.clientX; + _pointerY=e.clientY; + _commitSessionSwipe(); if(_longPressMenuOpened||_swipeHandled){e.stopPropagation();return;} if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){ closeSessionActionMenu(); @@ -3293,7 +3353,7 @@ function renderSessionListFromCache(){ 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;} + if(_isDragging){clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0;_clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50);return;} const now=Date.now(); if(now-_lastTapTime<350){ // Double-tap: rename @@ -3321,8 +3381,12 @@ 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 */ } } - await loadSession(s.session_id);renderSessionListFromCache(); - if(typeof closeMobileSidebar==='function')closeMobileSidebar(); + try{ + await loadSession(s.session_id);renderSessionListFromCache(); + if(typeof closeMobileSidebar==='function')closeMobileSidebar(); + }finally{ + el.classList.remove('loading'); + } }, delay); }; // Add ondblclick for more reliable double-click detection @@ -3343,13 +3407,16 @@ function renderSessionListFromCache(){ el.addEventListener('touchstart',(e)=>{ const touch=e.changedTouches&&e.changedTouches[0]; if(!touch) return; - _beginSessionTouchGesture(touch.clientX,touch.clientY); + _beginSessionGesture(touch.clientX,touch.clientY); + _gesturePointerType='touch'; _scheduleSessionLongPressMenu(); },{passive:true}); el.addEventListener('touchmove',(e)=>{ if(!_pointerActive) return; const touch=e.changedTouches&&e.changedTouches[0]; if(!touch) return; + _pointerX=touch.clientX; + _pointerY=touch.clientY; const signedDx=touch.clientX-_pointerDownX; const signedDy=touch.clientY-_pointerDownY; const dx=Math.abs(signedDx); @@ -3360,15 +3427,22 @@ function renderSessionListFromCache(){ el.classList.add('dragging'); if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} } - if(dx>16&&dx>dy*1.2) e.preventDefault(); - _handleSessionSwipe(signedDx,signedDy); + _trackHorizontalSwipe(dx,dy); + if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx); + if(_swipeTracking) e.preventDefault(); },{passive:false}); - el.addEventListener('touchend',()=>{ + el.addEventListener('touchend',(e)=>{ _pointerActive=false; _clearLongPressTimer(); + const touch=e.changedTouches&&e.changedTouches[0]; + if(touch){ + _pointerX=touch.clientX; + _pointerY=touch.clientY; + } + _commitSessionSwipe(); if(_isDragging){ _isDragging=false; - _clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50); + if(!_swipeHandled) _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); } },{passive:true}); return el; @@ -3474,19 +3548,20 @@ 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; + if(!ok)return false; + if(beforeDelete) await beforeDelete(); let response=null; try{ response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); _clearHandoffStorageForSession(sid); - }catch(e){setStatus(`Delete failed: ${e.message}`);return;} + }catch(e){setStatus(`Delete failed: ${e.message}`);return false;} if(S.session&&S.session.session_id===sid){ S.session=null;S.messages=[];S.entries=[]; localStorage.removeItem('hermes-webui-session'); @@ -3506,6 +3581,7 @@ async function deleteSession(sid){ } showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted')); await renderSessionList(); + return true; } // ── Project helpers ───────────────────────────────────────────────────── diff --git a/static/style.css b/static/style.css index 87196b48eb..5ac6ae79f2 100644 --- a/static/style.css +++ b/static/style.css @@ -429,18 +429,65 @@ 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:pan-y;-webkit-tap-highlight-color:transparent;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;} + .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(.2,.8,.2,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:translateX(var(--session-swipe-offset,0));} .session-item.streaming,.session-item.unread,.session-item:focus-within,.session-item.menu-open{padding-right:40px;} @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) 18%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 60%,transparent);} + .session-item.swiping-left{background:color-mix(in srgb,var(--error) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 60%,transparent);} + .session-item.swiping-right::after,.session-item.swiping-left::after{ + position:absolute; + top:0; + bottom:0; + display:flex; + align-items:center; + width:72px; + padding:0 10px; + box-sizing:border-box; + opacity:var(--session-swipe-progress,0); + font-size:10px; + font-weight:700; + letter-spacing:.06em; + text-transform:uppercase; + pointer-events:none; + } + .session-item.swiping-right::after{ + content:"Archive"; + left:0; + justify-content:flex-start; + transform:translateX(calc(-1 * var(--session-swipe-offset,0px))); + color:var(--warning); + background:transparent; + } + .session-item.swiping-left::after{ + content:"Delete"; + right:0; + justify-content:flex-end; + transform:translateX(calc(-1 * var(--session-swipe-offset,0px))); + color:var(--error); + background:transparent; + } + .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} + .session-item.swipe-committed{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} + .session-item.swipe-committed::after{transition:opacity .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) 18%,var(--surface));} + .session-item.dragging.swiping-left{background:color-mix(in srgb,var(--error) 16%,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){: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) 22%,var(--accent-bg));} + .session-item.active.swiping-left{background:color-mix(in srgb,var(--error) 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;} @@ -500,7 +547,7 @@ .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: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-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;} @@ -516,6 +563,7 @@ /* 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 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;} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index c4f917aa6d..64d96cc7fd 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -19,20 +19,23 @@ def test_session_menu_has_subtle_open_animation(): 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 "{duration:450, 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 ".session-action-menu.open-animated{animation:session-menu-in .45s 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 "_longPressDelay=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 _beginSessionTouchGesture=(clientX,clientY)=>{" in SESSIONS_JS + assert "const _beginSessionGesture=(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:6px;}" in mobile_touch @@ -52,13 +55,61 @@ def test_open_session_menu_consumes_next_row_activation(): def test_session_swipes_archive_right_and_delete_left(): - assert "_swipeActionThreshold=96" in SESSIONS_JS + assert "_gesturePointerType!=='mouse'" in SESSIONS_JS + assert "_swipeTracking=true" in SESSIONS_JS + assert "const _trackHorizontalSwipe=(dx,dy)=>{" in SESSIONS_JS + assert "_swipeActionThreshold=144" in SESSIONS_JS + assert "_committedSwipeDuration=420" in SESSIONS_JS assert "const _handleSessionSwipe=(signedDx,signedDy)=>{" in SESSIONS_JS + assert "if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx)" in SESSIONS_JS + assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" 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 "_archiveSession(s,!s.archived)" in SESSIONS_JS + assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS + assert "showToast('Imported sessions cannot be deleted here.',3000);" in SESSIONS_JS + assert "_swipeHandled=false;" in SESSIONS_JS assert "if(!_isDragging&&(dx>5||dy>5))" in SESSIONS_JS - assert "_handleSessionSwipe(signedDx,signedDy)" in SESSIONS_JS + assert "const _commitSessionSwipe=()=>{" in SESSIONS_JS + assert "_commitSessionSwipe();" in SESSIONS_JS + + +def test_session_swipes_show_visual_feedback_and_touch_load_clears(): + assert "const _paintSessionSwipe=(signedDx)=>{" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-offset',offset+'px')" in SESSIONS_JS + assert "const progress=Math.min(1,Math.abs(offset)/72)" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5))" in SESSIONS_JS + assert "const _clearSessionSwipePaint=()=>{" in SESSIONS_JS + assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS + assert "const _completeSessionSwipePaint=(signedDx)=>{" in SESSIONS_JS + assert "el.classList.add('swipe-committed')" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-progress','0')" in SESSIONS_JS + assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS + assert "const archived=await _archiveSession(s,!s.archived);" in SESSIONS_JS + assert "if(!archived) _settleSessionSwipePaint();" in SESSIONS_JS + assert "await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration));" in SESSIONS_JS + assert "async function deleteSession(sid, beforeDelete=null){" in SESSIONS_JS + assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS + assert ".session-item.swiping-right" in STYLE_CSS + assert ".session-item.swiping-left" in STYLE_CSS + assert "opacity:var(--session-swipe-progress,0)" in STYLE_CSS + assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px)))" in STYLE_CSS + assert STYLE_CSS.count("background:transparent;") >= 3 + assert "transform .5s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS + assert ".session-item.dragging.swiping-right" in STYLE_CSS + assert ".session-item.dragging.swiping-left" in STYLE_CSS + assert ".session-item.active.swiping-right" in STYLE_CSS + assert ".session-item.active.swiping-left" in STYLE_CSS + assert 'content:"Archive"' in STYLE_CSS + assert 'content:"Delete"' in STYLE_CSS + assert ".session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;}" in STYLE_CSS + assert ".session-item.swipe-committed" in STYLE_CSS + assert "transform .42s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS + assert ".session-item.swipe-committed::after{transition:opacity .18s ease;}" in STYLE_CSS + assert ".session-item.long-pressing" in STYLE_CSS + assert "@keyframes session-long-press" in STYLE_CSS + assert "transform:translateX(var(--session-swipe-offset,0))" in STYLE_CSS + assert "finally{" in SESSIONS_JS + assert "el.classList.remove('loading');" in SESSIONS_JS def test_ios_touch_events_drive_session_swipes(): From a5254509a3a63627f96d9842dd315dc60f30c2de Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sun, 17 May 2026 08:03:51 -0600 Subject: [PATCH 005/349] refactor implementation and clean up --- static/sessions.js | 74 ++++++++++++++--------------- tests/test_session_touch_actions.py | 6 ++- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index ba496d0a64..cfcd301cd8 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3181,17 +3181,15 @@ function renderSessionListFromCache(){ // single-tap navigation. pointerup fires immediately on both mouse & touch. // 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; let _longPressTimer=null; let _longPressMenuOpened=false; - let _swipeHandled=false; let _swipeTracking=false; let _pointerX=0; let _pointerY=0; @@ -3205,16 +3203,14 @@ function renderSessionListFromCache(){ if(!_longPressMenuOpened) el.classList.remove('long-pressing'); }; const _beginSessionGesture=(clientX,clientY)=>{ - _pointerActive=true; _gesturePointerType=''; _pointerDownX=clientX; _pointerDownY=clientY; _pointerX=clientX; _pointerY=clientY; - _isDragging=false; + _gestureState='pressing'; _swipeTracking=false; _longPressMenuOpened=false; - _swipeHandled=false; if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} el.classList.remove('dragging','swipe-committed'); }; @@ -3222,7 +3218,7 @@ function renderSessionListFromCache(){ _clearLongPressTimer(); el.classList.add('long-pressing'); _longPressTimer=setTimeout(()=>{ - if(!_pointerActive||_isDragging||_renamingSid||_sessionSelectMode||readOnly) return; + if(_gestureState!=='pressing'||_renamingSid||_sessionSelectMode||readOnly) return; _longPressMenuOpened=true; clearTimeout(_tapTimer); _tapTimer=null; @@ -3236,6 +3232,13 @@ function renderSessionListFromCache(){ const _trackHorizontalSwipe=(dx,dy)=>{ if(dx>16&&dx>dy*1.2) _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 _canSwipeDeleteSession=()=>{ return _isSessionSwipeTarget()&&!_isMessagingSession(s)&&!_isCliSession(s); }; @@ -3263,10 +3266,10 @@ function renderSessionListFromCache(){ el.style.setProperty('--session-swipe-offset',(signedDx>0?1:-1)*window.innerWidth+'px'); }; const _handleSessionSwipe=(signedDx,signedDy)=>{ - if(_swipeHandled||!_isSessionSwipeTarget()) return false; + if(_gestureState==='committed'||!_isSessionSwipeTarget()) return false; if(Math.abs(signedDx)<_swipeActionThreshold) return false; if(Math.abs(signedDy)>Math.abs(signedDx)*_swipeCancelRatio) return false; - _swipeHandled=true; + _gestureState='committed'; _clearLongPressTimer(); clearTimeout(_tapTimer); _tapTimer=null; @@ -3287,7 +3290,7 @@ function renderSessionListFromCache(){ }); }else if(typeof showToast==='function'){ showToast('Imported sessions cannot be deleted here.',3000); - _swipeHandled=false; + _gestureState='dragging'; _settleSessionSwipePaint(); } return true; @@ -3296,10 +3299,10 @@ function renderSessionListFromCache(){ return _handleSessionSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY); }; const _clearPointerDragState=()=>{ - _pointerActive=false; + const wasDragging=_gestureState==='dragging'; + _gestureState='idle'; _clearLongPressTimer(); - if(_isDragging){ - _isDragging=false; + if(wasDragging){ if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); } @@ -3316,35 +3319,31 @@ function renderSessionListFromCache(){ // 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(_gestureState==='idle') return; _pointerX=e.clientX; _pointerY=e.clientY; const dx=Math.abs(e.clientX-_pointerDownX); const dy=Math.abs(e.clientY-_pointerDownY); - 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;} - } + _promoteSessionDrag(dx,dy); const signedDx=e.clientX-_pointerDownX; const signedDy=e.clientY-_pointerDownY; _trackHorizontalSwipe(Math.abs(signedDx),Math.abs(signedDy)); if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx); }; el.onpointercancel=_clearPointerDragState; - el.onpointerleave=()=>{ if(_pointerActive) _clearPointerDragState(); }; + el.onpointerleave=()=>{ + if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState(); + }; el.onpointerup=(e)=>{ if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click - _pointerActive=false; + const wasDragging=_gestureState==='dragging'; _clearLongPressTimer(); if(_renamingSid) return; if(actions&&actions.contains(e.target)) return; _pointerX=e.clientX; _pointerY=e.clientY; _commitSessionSwipe(); - if(_longPressMenuOpened||_swipeHandled){e.stopPropagation();return;} + if(_longPressMenuOpened||_gestureState==='committed'){e.stopPropagation();return;} if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){ closeSessionActionMenu(); e.stopPropagation(); @@ -3353,7 +3352,13 @@ function renderSessionListFromCache(){ 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(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50);return;} + if(wasDragging){ + clearTimeout(_tapTimer);_tapTimer=null;_lastTapTime=0; + _gestureState='idle'; + _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); + return; + } + _gestureState='idle'; const now=Date.now(); if(now-_lastTapTime<350){ // Double-tap: rename @@ -3412,7 +3417,7 @@ function renderSessionListFromCache(){ _scheduleSessionLongPressMenu(); },{passive:true}); el.addEventListener('touchmove',(e)=>{ - if(!_pointerActive) return; + if(_gestureState==='idle') return; const touch=e.changedTouches&&e.changedTouches[0]; if(!touch) return; _pointerX=touch.clientX; @@ -3421,18 +3426,13 @@ function renderSessionListFromCache(){ 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;} - } + _promoteSessionDrag(dx,dy); _trackHorizontalSwipe(dx,dy); if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx); if(_swipeTracking) e.preventDefault(); },{passive:false}); el.addEventListener('touchend',(e)=>{ - _pointerActive=false; + const wasDragging=_gestureState==='dragging'; _clearLongPressTimer(); const touch=e.changedTouches&&e.changedTouches[0]; if(touch){ @@ -3440,10 +3440,10 @@ function renderSessionListFromCache(){ _pointerY=touch.clientY; } _commitSessionSwipe(); - if(_isDragging){ - _isDragging=false; - if(!_swipeHandled) _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); + if(wasDragging){ + if(_gestureState!=='committed') _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); } + if(_gestureState!=='committed') _gestureState='idle'; },{passive:true}); return el; } diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 64d96cc7fd..edc99c8af3 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -67,8 +67,10 @@ def test_session_swipes_archive_right_and_delete_left(): assert "_archiveSession(s,!s.archived)" in SESSIONS_JS assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS assert "showToast('Imported sessions cannot be deleted here.',3000);" in SESSIONS_JS - assert "_swipeHandled=false;" in SESSIONS_JS - assert "if(!_isDragging&&(dx>5||dy>5))" in SESSIONS_JS + assert "let _gestureState='idle';" in SESSIONS_JS + assert "_gestureState='dragging';" in SESSIONS_JS + assert "const _promoteSessionDrag=(dx,dy)=>{" in SESSIONS_JS + assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS assert "const _commitSessionSwipe=()=>{" in SESSIONS_JS assert "_commitSessionSwipe();" in SESSIONS_JS From 22aae4867295217b5040936c47c7df28e780ba16 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sun, 17 May 2026 11:00:19 -0600 Subject: [PATCH 006/349] Polish session swipe affordances --- static/icons.js | 1 + static/sessions.js | 19 +++++++++++ static/style.css | 51 +++++++++++++++++++---------- tests/test_session_touch_actions.py | 15 ++++++--- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/static/icons.js b/static/icons.js index 10e5eb78c6..f965f932dd 100644 --- a/static/icons.js +++ b/static/icons.js @@ -21,6 +21,7 @@ const LI_PATHS = { 'upload': '', 'braces': '', 'trash-2': '', + 'archive': '', 'settings': '', 'alert-triangle': '', 'refresh-cw': '', diff --git a/static/sessions.js b/static/sessions.js index cfcd301cd8..3903accacd 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3175,6 +3175,25 @@ function renderSessionListFromCache(){ el.appendChild(actions); } + const _makeSessionSwipeAffordance=(side,icon,label)=>{ + const affordance=document.createElement('div'); + affordance.className='session-swipe-affordance session-swipe-affordance-'+side; + 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; + affordance.append(badge,text); + return affordance; + }; + if(!readOnly){ + el.append( + _makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?t('session_restore'):t('session_batch_archive')), + _makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')), + ); + } + // Use pointerup + 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 diff --git a/static/style.css b/static/style.css index 5ac6ae79f2..a8c269d733 100644 --- a/static/style.css +++ b/static/style.css @@ -441,41 +441,58 @@ } .session-item.swiping-right{background:color-mix(in srgb,var(--warning) 18%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 60%,transparent);} .session-item.swiping-left{background:color-mix(in srgb,var(--error) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 60%,transparent);} - .session-item.swiping-right::after,.session-item.swiping-left::after{ + .session-swipe-affordance{ position:absolute; top:0; bottom:0; display:flex; + flex-direction:column; align-items:center; + justify-content:center; + gap:3px; width:72px; padding:0 10px; box-sizing:border-box; - opacity:var(--session-swipe-progress,0); - font-size:10px; - font-weight:700; - letter-spacing:.06em; - text-transform:uppercase; + opacity:0; + transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18)); + transform-origin:center; pointer-events:none; } - .session-item.swiping-right::after{ - content:"Archive"; + .session-swipe-affordance-right{ left:0; - justify-content:flex-start; - transform:translateX(calc(-1 * var(--session-swipe-offset,0px))); color:var(--warning); - background:transparent; } - .session-item.swiping-left::after{ - content:"Delete"; + .session-swipe-affordance-left{ right:0; - justify-content:flex-end; - transform:translateX(calc(-1 * var(--session-swipe-offset,0px))); color:var(--error); - background:transparent; + } + .session-item.swiping-right .session-swipe-affordance-right, + .session-item.swiping-left .session-swipe-affordance-left{ + opacity:var(--session-swipe-progress,0); + } + .session-swipe-badge{ + width:30px; + height:30px; + border-radius:999px; + display:flex; + align-items:center; + justify-content:center; + color:#fff; + } + .session-swipe-affordance-right .session-swipe-badge{background:var(--warning);} + .session-swipe-affordance-left .session-swipe-badge{background:var(--error);} + .session-swipe-label{ + max-width:58px; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + font-size:10px; + font-weight:600; + line-height:1; } .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} .session-item.swipe-committed{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} - .session-item.swipe-committed::after{transition:opacity .18s ease;} + .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) 18%,var(--surface));} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index edc99c8af3..1843adec00 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -93,20 +93,25 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS assert ".session-item.swiping-right" in STYLE_CSS assert ".session-item.swiping-left" in STYLE_CSS + assert "const _makeSessionSwipeAffordance=(side,icon,label)=>{" in SESSIONS_JS + assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive'" in SESSIONS_JS + assert "_makeSessionSwipeAffordance('left','trash-2'" in SESSIONS_JS + assert ".session-swipe-affordance{" in STYLE_CSS assert "opacity:var(--session-swipe-progress,0)" in STYLE_CSS - assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px)))" in STYLE_CSS - assert STYLE_CSS.count("background:transparent;") >= 3 + assert ".session-item.swiping-right .session-swipe-affordance-right" in STYLE_CSS + assert ".session-item.swiping-left .session-swipe-affordance-left" in STYLE_CSS + assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" in STYLE_CSS + assert ".session-swipe-badge{" in STYLE_CSS + assert ".session-swipe-label{" in STYLE_CSS assert "transform .5s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS assert ".session-item.dragging.swiping-right" in STYLE_CSS assert ".session-item.dragging.swiping-left" in STYLE_CSS assert ".session-item.active.swiping-right" in STYLE_CSS assert ".session-item.active.swiping-left" in STYLE_CSS - assert 'content:"Archive"' in STYLE_CSS - assert 'content:"Delete"' in STYLE_CSS assert ".session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;}" in STYLE_CSS assert ".session-item.swipe-committed" in STYLE_CSS assert "transform .42s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS - assert ".session-item.swipe-committed::after{transition:opacity .18s ease;}" in STYLE_CSS + assert ".session-item.swipe-committed .session-swipe-affordance{transition:opacity .18s ease,transform .18s ease;}" in STYLE_CSS assert ".session-item.long-pressing" in STYLE_CSS assert "@keyframes session-long-press" in STYLE_CSS assert "transform:translateX(var(--session-swipe-offset,0))" in STYLE_CSS From 587101fb97c32b0a53b8512e545e9ecc41534100 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sun, 17 May 2026 11:55:56 -0600 Subject: [PATCH 007/349] Animate session list reflow on removal --- static/sessions.js | 45 +++++++++++++++++++++++++++-- static/style.css | 9 ++++++ tests/test_session_touch_actions.py | 17 +++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 3903accacd..6c5cab5dbd 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1303,6 +1303,38 @@ const _lineageReportCache = new Map(); const _lineageReportInflight = new Map(); let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; +let _pendingSessionReflowPositions = null; + +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 _playQueuedSessionReflowAnimation(){ + const before=_pendingSessionReflowPositions; + _pendingSessionReflowPositions=null; + if(!before||!before.size) return; + const reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if(reduce) 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||typeof row.animate!=='function') return; + const anim=row.animate( + [{transform:`translateY(${delta}px)`},{transform:'translateY(0)'}], + {duration:360,easing:'cubic-bezier(.2,.8,.2,1)'} + ); + if(anim&&anim.finished) anim.finished.catch(()=>{}); + }); +} const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; const SESSION_VIRTUAL_THRESHOLD_ROWS = 80; @@ -1609,14 +1641,16 @@ function _playSessionActionMenuEntrance(menu){ async function _archiveSession(session, archived=true){ if(_isReadOnlySession(session)){ if(typeof showToast==='function') showToast('Read-only imported sessions cannot be modified.',3000); return false; } + const reflowPositions=_captureSessionReflowPositions(); 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; + _pendingSessionReflowPositions=reflowPositions; await renderSessionList(); showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); return true; - }catch(err){showToast(t('session_archive_failed')+err.message);return false;} + }catch(err){_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;} } function _openSessionActionMenu(session, anchorEl){ @@ -2841,6 +2875,7 @@ function renderSessionListFromCache(){ toggleBtn.onclick=(e)=>{e.stopPropagation();toggleSessionSelectMode();}; list.appendChild(toggleBtn); } + _playQueuedSessionReflowAnimation(); // Note: declared after the groups loop but available via function hoisting. function _renderOneSession(s, isPinnedGroup=false){ const el=document.createElement('div'); @@ -3283,6 +3318,10 @@ function renderSessionListFromCache(){ 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; @@ -3575,12 +3614,13 @@ async function deleteSession(sid, beforeDelete=null){ danger:true }); if(!ok)return false; + const reflowPositions=_captureSessionReflowPositions(); if(beforeDelete) await beforeDelete(); let response=null; try{ response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); _clearHandoffStorageForSession(sid); - }catch(e){setStatus(`Delete failed: ${e.message}`);return false;} + }catch(e){_pendingSessionReflowPositions=null;setStatus(`Delete failed: ${e.message}`);return false;} if(S.session&&S.session.session_id===sid){ S.session=null;S.messages=[];S.entries=[]; localStorage.removeItem('hermes-webui-session'); @@ -3599,6 +3639,7 @@ async function deleteSession(sid, beforeDelete=null){ } } showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted')); + _pendingSessionReflowPositions=reflowPositions; await renderSessionList(); return true; } diff --git a/static/style.css b/static/style.css index a8c269d733..73c10f0384 100644 --- a/static/style.css +++ b/static/style.css @@ -492,6 +492,15 @@ } .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} .session-item.swipe-committed{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} + .session-item.swipe-removing{ + min-height:0!important; + height:0!important; + margin-bottom:0; + padding-top:0; + padding-bottom:0; + overflow:hidden; + transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease,height .36s cubic-bezier(.2,.8,.2,1),min-height .36s cubic-bezier(.2,.8,.2,1),margin-bottom .36s cubic-bezier(.2,.8,.2,1),padding-top .36s cubic-bezier(.2,.8,.2,1),padding-bottom .36s cubic-bezier(.2,.8,.2,1); + } .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);} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 1843adec00..4b88685d87 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -84,6 +84,8 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS assert "const _completeSessionSwipePaint=(signedDx)=>{" in SESSIONS_JS assert "el.classList.add('swipe-committed')" in SESSIONS_JS + assert "el.style.height=rect.height+'px'" in SESSIONS_JS + assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in SESSIONS_JS assert "el.style.setProperty('--session-swipe-progress','0')" in SESSIONS_JS assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS assert "const archived=await _archiveSession(s,!s.archived);" in SESSIONS_JS @@ -110,6 +112,8 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert ".session-item.active.swiping-left" in STYLE_CSS assert ".session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;}" in STYLE_CSS assert ".session-item.swipe-committed" in STYLE_CSS + assert ".session-item.swipe-removing{" in STYLE_CSS + assert "height .36s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS assert "transform .42s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS assert ".session-item.swipe-committed .session-swipe-affordance{transition:opacity .18s ease,transform .18s ease;}" in STYLE_CSS assert ".session-item.long-pressing" in STYLE_CSS @@ -119,6 +123,19 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "el.classList.remove('loading');" in SESSIONS_JS +def test_session_removal_reflows_surviving_rows_smoothly(): + assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS + assert "function _captureSessionReflowPositions(){" in SESSIONS_JS + assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in SESSIONS_JS + assert "function _playQueuedSessionReflowAnimation(){" in SESSIONS_JS + assert "window.matchMedia('(prefers-reduced-motion: reduce)').matches" in SESSIONS_JS + assert "const delta=oldTop-row.getBoundingClientRect().top;" in SESSIONS_JS + assert "{duration:360,easing:'cubic-bezier(.2,.8,.2,1)'}" in SESSIONS_JS + assert SESSIONS_JS.count("const reflowPositions=_captureSessionReflowPositions();") >= 2 + assert SESSIONS_JS.count("_pendingSessionReflowPositions=reflowPositions;") >= 2 + assert "_playQueuedSessionReflowAnimation();" 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 From 5db7aa43aab0164d3886caa4b2f0a69a5ed4eabc Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sun, 17 May 2026 12:02:56 -0600 Subject: [PATCH 008/349] smooth flip animation on delete/archive for session stack --- static/sessions.js | 34 +++++++++++++++++++++-------- static/style.css | 5 ++++- tests/test_session_touch_actions.py | 11 +++++++--- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 6c5cab5dbd..710a7e386e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1319,7 +1319,7 @@ function _playQueuedSessionReflowAnimation(){ const before=_pendingSessionReflowPositions; _pendingSessionReflowPositions=null; if(!before||!before.size) return; - const reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const reduce=_sessionPrefersReducedMotion(); if(reduce) return; const list=$('sessionList'); if(!list) return; @@ -1327,14 +1327,30 @@ function _playQueuedSessionReflowAnimation(){ const oldTop=before.get(row.dataset.sid); if(oldTop===undefined) return; const delta=oldTop-row.getBoundingClientRect().top; - if(Math.abs(delta)<1||typeof row.animate!=='function') return; - const anim=row.animate( - [{transform:`translateY(${delta}px)`},{transform:'translateY(0)'}], - {duration:360,easing:'cubic-bezier(.2,.8,.2,1)'} - ); - if(anim&&anim.finished) anim.finished.catch(()=>{}); + 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 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); + setTimeout(clearReflow,420); }); } + +function _sessionPrefersReducedMotion(){ + return Boolean(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches); +} const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; const SESSION_VIRTUAL_THRESHOLD_ROWS = 80; @@ -1621,7 +1637,7 @@ function _appendSessionDuplicateAction(menu, session){ function _playSessionActionMenuEntrance(menu){ if(!menu) return; - const reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const reduce=_sessionPrefersReducedMotion(); if(reduce) return; if(typeof menu.animate==='function'){ try{ @@ -3251,7 +3267,7 @@ function renderSessionListFromCache(){ const _longPressDelay=400; const _swipeActionThreshold=144; const _swipeCancelRatio=0.75; - const _committedSwipeDuration=420; + const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:420; const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} if(!_longPressMenuOpened) el.classList.remove('long-pressing'); diff --git a/static/style.css b/static/style.css index 73c10f0384..bfdc54be2c 100644 --- a/static/style.css +++ b/static/style.css @@ -429,7 +429,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,transform .5s cubic-bezier(.2,.8,.2,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:translateX(var(--session-swipe-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(.2,.8,.2,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:translateX(var(--session-swipe-offset,0)) translateY(var(--session-reflow-offset,0));} .session-item.streaming,.session-item.unread,.session-item:focus-within,.session-item.menu-open{padding-right:40px;} @media (hover:hover){.session-item:hover{background:var(--hover-bg);color:var(--text);}} .session-item.loading{background:var(--hover-bg);color:var(--text);} @@ -491,6 +491,7 @@ line-height:1; } .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} + .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{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} .session-item.swipe-removing{ min-height:0!important; @@ -585,6 +586,8 @@ .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.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;} + .session-item.long-pressing{animation:none;} } /* Hide overlay during inline rename */ .session-item:has(.session-title-input) .session-actions{display:none;} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 4b88685d87..ee4363565b 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -25,6 +25,8 @@ def test_session_menu_has_subtle_open_animation(): 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 + assert ".session-item,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;}" in STYLE_CSS + assert ".session-item.long-pressing{animation:none;}" in STYLE_CSS def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): @@ -59,7 +61,7 @@ def test_session_swipes_archive_right_and_delete_left(): assert "_swipeTracking=true" in SESSIONS_JS assert "const _trackHorizontalSwipe=(dx,dy)=>{" in SESSIONS_JS assert "_swipeActionThreshold=144" in SESSIONS_JS - assert "_committedSwipeDuration=420" in SESSIONS_JS + assert "_committedSwipeDuration=_sessionPrefersReducedMotion()?0:420" in SESSIONS_JS assert "const _handleSessionSwipe=(signedDx,signedDy)=>{" in SESSIONS_JS assert "if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx)" in SESSIONS_JS assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS @@ -128,12 +130,15 @@ def test_session_removal_reflows_surviving_rows_smoothly(): assert "function _captureSessionReflowPositions(){" in SESSIONS_JS assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in SESSIONS_JS assert "function _playQueuedSessionReflowAnimation(){" in SESSIONS_JS - assert "window.matchMedia('(prefers-reduced-motion: reduce)').matches" in SESSIONS_JS + assert "function _sessionPrefersReducedMotion(){" in SESSIONS_JS assert "const delta=oldTop-row.getBoundingClientRect().top;" in SESSIONS_JS - assert "{duration:360,easing:'cubic-bezier(.2,.8,.2,1)'}" in SESSIONS_JS + assert "row.style.setProperty('--session-reflow-offset',delta+'px')" in SESSIONS_JS + assert "row.classList.add('session-reflowing')" in SESSIONS_JS + assert "row.style.setProperty('--session-reflow-offset','0px')" in SESSIONS_JS assert SESSIONS_JS.count("const reflowPositions=_captureSessionReflowPositions();") >= 2 assert SESSIONS_JS.count("_pendingSessionReflowPositions=reflowPositions;") >= 2 assert "_playQueuedSessionReflowAnimation();" 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 def test_ios_touch_events_drive_session_swipes(): From a902bfb9053e29958d8c36ae185bb936d40b9c72 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sun, 17 May 2026 12:33:48 -0600 Subject: [PATCH 009/349] let session swipes continue past action reveal --- static/sessions.js | 7 +++++-- tests/test_session_touch_actions.py | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 710a7e386e..7c6da6a149 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3313,8 +3313,11 @@ function renderSessionListFromCache(){ return _isSessionSwipeTarget()&&!_isMessagingSession(s)&&!_isCliSession(s); }; const _paintSessionSwipe=(signedDx)=>{ - const offset=Math.max(-72,Math.min(72,signedDx*.55)); - const progress=Math.min(1,Math.abs(offset)/72); + 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); el.style.setProperty('--session-swipe-offset',offset+'px'); el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5)); el.classList.toggle('swiping-right',offset>0); diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index ee4363565b..8e373fd515 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -79,8 +79,12 @@ def test_session_swipes_archive_right_and_delete_left(): def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "const _paintSessionSwipe=(signedDx)=>{" in SESSIONS_JS + assert "const rawOffset=signedDx*.55" in SESSIONS_JS + assert "const revealedOffset=Math.max(-72,Math.min(72,rawOffset))" in SESSIONS_JS + assert "const overshoot=Math.max(0,Math.abs(rawOffset)-72)" in SESSIONS_JS + assert "Math.sqrt(overshoot)*5" in SESSIONS_JS assert "el.style.setProperty('--session-swipe-offset',offset+'px')" in SESSIONS_JS - assert "const progress=Math.min(1,Math.abs(offset)/72)" in SESSIONS_JS + assert "const progress=Math.min(1,Math.abs(revealedOffset)/72)" in SESSIONS_JS assert "el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5))" in SESSIONS_JS assert "const _clearSessionSwipePaint=()=>{" in SESSIONS_JS assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS From ab3fd4ceca4a1a4bd313325b699a233da03ed53e Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sun, 17 May 2026 12:54:11 -0600 Subject: [PATCH 010/349] Update hover regression for gesture state machine --- tests/test_issue856_pinned_indicator_layout.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_issue856_pinned_indicator_layout.py b/tests/test_issue856_pinned_indicator_layout.py index 93b2d6440d..09785d40fb 100644 --- a/tests/test_issue856_pinned_indicator_layout.py +++ b/tests/test_issue856_pinned_indicator_layout.py @@ -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;" in SESSIONS_JS + assert "_gestureState='idle';" in SESSIONS_JS assert ".session-item.dragging:hover" in STYLE_CSS From a48e47dd1cd53115eccdd802461df1ebb6a63d15 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sun, 17 May 2026 20:40:20 -0700 Subject: [PATCH 011/349] feat: separate CLI sessions in sidebar --- CHANGELOG.md | 4 ++ docs/pr-media/2351/after-source-tabs.png | Bin 0 -> 2355 bytes docs/pr-media/2351/before-cli-mixed.png | Bin 0 -> 2355 bytes static/sessions.js | 59 +++++++++++++++++- static/style.css | 5 ++ ...est_issue2351_cli_session_source_filter.py | 31 +++++++++ 6 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 docs/pr-media/2351/after-source-tabs.png create mode 100644 docs/pr-media/2351/before-cli-mixed.png create mode 100644 tests/test_issue2351_cli_session_source_filter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ed15eec371..04bf9c79da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **PR #2506** by @Michaelyklam (refs #2351) — Add a read-only WebUI/CLI session source switch in the chat sidebar when agent session sync is enabled. WebUI conversations stay in the default list, while imported CLI/agent sessions are surfaced under a separate `CLI sessions` tab with counts so large CLI histories do not clutter the normal conversation list. + ## [v0.51.91] — 2026-05-18 — Release BO (stage-384 — 5-PR full sweep batch — reasoning-replay history fix + archive-extract per-session inbox + fallback streaming warnings + sanitized custom-provider env hints + Slice 3c queue/goal adapter routing) ### Fixed diff --git a/docs/pr-media/2351/after-source-tabs.png b/docs/pr-media/2351/after-source-tabs.png new file mode 100644 index 0000000000000000000000000000000000000000..2279fd3004940274c8e3f47d4f10930c3f6cf87a GIT binary patch literal 2355 zcmeAS@N?(olHy`uVBq!ia0y~yU~FSxV7kD;1Qe0J>u{8Tf#Zdzi(^Q|oHtht1sN20 z7&gisk7E8I-qkBFB)D^TK7-Ubh7=7>jzA%SMJfs|o(>(88U$1s6}_08rm(b(N{j{x zrHR2nb~G)GX0OqLWwb~gtz|~*SDLjfJ~1*d{Qp0H!p|4LrVksa&BM&lBE@=rfyVu5 PKoJH{S3j3^P6u{8Tf#Zdzi(^Q|oHtht1sN20 z7&gisk7E8I-qkBFB)D^TK7-Ubh7=7>jzA%SMJfs|o(>(88U$1s6}_08rm(b(N{j{x zrHR2nb~G)GX0OqLWwb~gtz|~*SDLjfJ~1*d{Qp0H!p|4LrVksa&BM&lBE@=rfyVu5 PKoJH{S3j3^P60) ); + const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length; + const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length; + if(_sessionSourceFilter==='cli' && !window._showCliSessions && cliSessionCount===0){ + _sessionSourceFilter='webui'; + } + const sourceFiltered = _sessionSourceFilter==='cli' + ? withMessages.filter(s=>_isCliSession(s)) + : withMessages.filter(s=>!_isCliSession(s)); // The server is authoritative for profile scoping (#1611): it filters by // active profile when no query param is set, and returns the aggregate when // we send ?all_profiles=1. The renamed-root cross-alias (a row tagged @@ -2733,7 +2766,7 @@ function renderSessionListFromCache(){ // in _profiles_match, and a strict-equality client filter would reject those // rows incorrectly. So we trust the wire data and skip the redundant client // filter entirely. - const profileFiltered=withMessages; + const profileFiltered=sourceFiltered; // Filter by active project. NO_PROJECT_FILTER sentinel asks for sessions // with no project_id; otherwise filter to the matching project_id, or // pass through when no filter is active. @@ -2768,6 +2801,21 @@ function renderSessionListFromCache(){ list.appendChild(batchBar); if(_sessionSelectMode&&_selectedSessions.size>0){batchBar.style.display='flex';_renderBatchActionBar();} else{batchBar.style.display='none';} + if(window._showCliSessions || cliSessionCount>0){ + const sourceTabs=document.createElement('div'); + sourceTabs.className='session-source-tabs'; + for(const filter of ['webui','cli']){ + const count=filter==='cli'?cliSessionCount:webuiSessionCount; + const btn=document.createElement('button'); + btn.type='button'; + btn.className='session-source-tab'+(_sessionSourceFilter===filter?' active':''); + btn.textContent=_sessionSourceLabel(filter,count); + btn.setAttribute('aria-pressed', _sessionSourceFilter===filter?'true':'false'); + btn.onclick=()=>_setSessionSourceFilter(filter); + sourceTabs.appendChild(btn); + } + list.appendChild(sourceTabs); + } // Project filter bar — show when there are real projects OR there are // unassigned sessions (so the Unassigned chip has something to filter to). const hasUnprojected=profileFiltered.some(s=>!s.project_id); @@ -2850,9 +2898,14 @@ function renderSessionListFromCache(){ list.appendChild(toggle); } // Empty state for active project filter - if(_activeProject&&sessions.length===0){ + if(_sessionSourceFilter==='cli'&&sessions.length===0){ + const empty=document.createElement('div'); + empty.className='session-empty-note'; + empty.textContent=window._showCliSessions?'No CLI sessions found.':'Enable Show agent sessions in Settings to list CLI sessions here.'; + list.appendChild(empty); + } else if(_activeProject&&sessions.length===0){ const empty=document.createElement('div'); - empty.style.cssText='padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;'; + empty.className='session-empty-note'; empty.textContent=_activeProject===NO_PROJECT_FILTER?'No unassigned sessions.':'No sessions in this project yet.'; list.appendChild(empty); } diff --git a/static/style.css b/static/style.css index e4714e214b..0921d97c44 100644 --- a/static/style.css +++ b/static/style.css @@ -3024,6 +3024,11 @@ main.main.showing-logs > #mainLogs{display:flex;} .mermaid-rendered svg{max-width:100%;height:auto;} /* ── Session projects ── */ +.session-source-tabs{display:flex;gap:4px;padding:4px 10px 8px;flex-shrink:0;} +.session-source-tab{flex:1;min-width:0;border:1px solid var(--border2);border-radius:10px;background:var(--input-bg);color:var(--muted);font-size:10px;font-weight:700;line-height:1.2;padding:5px 6px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s,border-color .15s;} +.session-source-tab:hover{background:rgba(255,255,255,.08);color:var(--text);} +.session-source-tab.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);} +.session-empty-note{padding:20px 14px;color:var(--muted);font-size:12px;text-align:center;opacity:.7;} .project-bar{display:flex;gap:4px;padding:4px 10px 8px;flex-wrap:wrap;align-items:center;flex-shrink:0;} .project-chip{font-size:10px;font-weight:600;padding:3px 8px;border-radius:12px;cursor:pointer;border:1px solid var(--border2);background:var(--input-bg);color:var(--muted);transition:all .15s;white-space:nowrap;display:inline-flex;align-items:center;gap:4px;} .project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);} diff --git a/tests/test_issue2351_cli_session_source_filter.py b/tests/test_issue2351_cli_session_source_filter.py new file mode 100644 index 0000000000..efe2a8f68b --- /dev/null +++ b/tests/test_issue2351_cli_session_source_filter.py @@ -0,0 +1,31 @@ +"""Regression coverage for issue #2351 CLI session list separation.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SESSIONS_JS = ROOT / "static" / "sessions.js" +STYLE_CSS = ROOT / "static" / "style.css" + + +def test_sidebar_has_separate_webui_and_cli_session_source_tabs(): + src = SESSIONS_JS.read_text(encoding="utf-8") + assert "let _sessionSourceFilter = 'webui'" in src + assert "hermes-session-source-filter" in src + assert "session-source-tabs" in src + assert "WebUI sessions" in src + assert "CLI sessions" in src + assert "_sessionSourceFilter==='cli'" in src + + +def test_cli_filter_keeps_cli_rows_out_of_default_webui_list(): + src = SESSIONS_JS.read_text(encoding="utf-8") + assert "const webuiSessionCount = withMessages.filter(s=>!_isCliSession(s)).length" in src + assert "const cliSessionCount = withMessages.filter(s=>_isCliSession(s)).length" in src + assert "? withMessages.filter(s=>_isCliSession(s))" in src + assert ": withMessages.filter(s=>!_isCliSession(s))" in src + + +def test_session_source_tabs_have_dedicated_sidebar_styles(): + css = STYLE_CSS.read_text(encoding="utf-8") + assert ".session-source-tabs" in css + assert ".session-source-tab.active" in css + assert ".session-empty-note" in css From ca6736407e34b821efab01bbcbc921e770d1fc8e Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Wed, 20 May 2026 21:24:35 -0600 Subject: [PATCH 012/349] Checkpoint polished session swipe interactions --- static/sessions.js | 184 +++++++++++++++------------- static/style.css | 42 ++++--- tests/test_session_touch_actions.py | 73 ++++++++--- 3 files changed, 183 insertions(+), 116 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 3b13b19503..836f2c8c96 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3615,6 +3615,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(); @@ -3637,6 +3640,7 @@ function renderSessionListFromCache(){ const _makeSessionSwipeAffordance=(side,icon,label)=>{ const affordance=document.createElement('div'); affordance.className='session-swipe-affordance session-swipe-affordance-'+side; + affordance.setAttribute('aria-hidden','true'); const badge=document.createElement('span'); badge.className='session-swipe-badge'; badge.innerHTML=li(icon,18); @@ -3653,10 +3657,10 @@ function renderSessionListFromCache(){ ); } - // Use pointerup + manual double-tap detection instead of onclick/ondblclick. + // 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. // Movement promotes pressing into dragging; drag release cancels a pending tap. @@ -3673,15 +3677,16 @@ function renderSessionListFromCache(){ let _pointerY=0; let _gesturePointerType=''; const _longPressDelay=400; - const _swipeActionThreshold=144; + const _archiveSwipeActionThreshold=128; + const _deleteSwipeActionThreshold=128; const _swipeCancelRatio=0.75; const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:420; const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} if(!_longPressMenuOpened) el.classList.remove('long-pressing'); }; - const _beginSessionGesture=(clientX,clientY)=>{ - _gesturePointerType=''; + const _beginSessionGesture=(clientX,clientY,pointerType='')=>{ + _gesturePointerType=pointerType; _pointerDownX=clientX; _pointerDownY=clientY; _pointerX=clientX; @@ -3690,7 +3695,9 @@ function renderSessionListFromCache(){ _swipeTracking=false; _longPressMenuOpened=false; if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} - el.classList.remove('dragging','swipe-committed'); + el.classList.remove('dragging','swipe-committed','swipe-removing'); + el.style.removeProperty('height'); + el.style.removeProperty('min-height'); }; const _scheduleSessionLongPressMenu=()=>{ _clearLongPressTimer(); @@ -3707,8 +3714,11 @@ function renderSessionListFromCache(){ const _isSessionSwipeTarget=()=>{ return _gesturePointerType!=='mouse'&&!readOnly&&!_renamingSid&&!_sessionSelectMode; }; + const _isSessionActionTarget=(target)=>{ + return !!(actions&&target&&actions.contains(target)); + }; const _trackHorizontalSwipe=(dx,dy)=>{ - if(dx>16&&dx>dy*1.2) _swipeTracking=true; + if(dx>8&&dx>dy*1.1) _swipeTracking=true; }; const _promoteSessionDrag=(dx,dy)=>{ if(_gestureState!=='pressing'||(dx<=5&&dy<=5)) return; @@ -3717,6 +3727,19 @@ function renderSessionListFromCache(){ 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); }; @@ -3726,15 +3749,23 @@ function renderSessionListFromCache(){ 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.min(132,Math.max(36,Math.abs(rawOffset)+24)); + const iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130); el.style.setProperty('--session-swipe-offset',offset+'px'); + el.style.setProperty('--session-swipe-reveal',reveal+'px'); + el.style.setProperty('--session-swipe-icon-scale',iconScale); el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5)); 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-icon-scale'); el.style.removeProperty('--session-swipe-progress'); - el.classList.remove('swiping-right','swiping-left','swipe-committed'); + 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'); @@ -3752,7 +3783,8 @@ function renderSessionListFromCache(){ }; const _handleSessionSwipe=(signedDx,signedDy)=>{ if(_gestureState==='committed'||!_isSessionSwipeTarget()) return false; - if(Math.abs(signedDx)<_swipeActionThreshold) return false; + const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold; + if(Math.abs(signedDx)Math.abs(signedDx)*_swipeCancelRatio) return false; _gestureState='committed'; _clearLongPressTimer(); @@ -3766,10 +3798,11 @@ function renderSessionListFromCache(){ if(!archived) _settleSessionSwipePaint(); },_committedSwipeDuration); }else if(_canSwipeDeleteSession()){ - el.classList.remove('dragging'); + _completeSessionSwipePaint(signedDx); + const completedAt=Date.now(); deleteSession(s.session_id,async()=>{ - _completeSessionSwipePaint(signedDx); - await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration)); + const remaining=_committedSwipeDuration-(Date.now()-completedAt); + if(remaining>0) await new Promise(resolve=>setTimeout(resolve,remaining)); }).then((deleted)=>{ if(!deleted) _settleSessionSwipePaint(); }); @@ -3784,7 +3817,11 @@ function renderSessionListFromCache(){ return _handleSessionSwipe(_pointerX-_pointerDownX,_pointerY-_pointerDownY); }; const _clearPointerDragState=()=>{ - const wasDragging=_gestureState==='dragging'; + if(_gestureState==='committed'){ + _clearLongPressTimer(); + return; + } + const wasDragging=_gestureState==='dragging'||_swipeTracking; _gestureState='idle'; _clearLongPressTimer(); if(wasDragging){ @@ -3792,75 +3829,42 @@ function renderSessionListFromCache(){ _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); } }; - el.onpointerdown=(e)=>{ - if(e.pointerType==='mouse' && e.button!==0) return; - _beginSessionGesture(e.clientX,e.clientY); - _gesturePointerType=e.pointerType||''; - 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(_gestureState==='idle') return; - _pointerX=e.clientX; - _pointerY=e.clientY; - const dx=Math.abs(e.clientX-_pointerDownX); - const dy=Math.abs(e.clientY-_pointerDownY); - _promoteSessionDrag(dx,dy); - const signedDx=e.clientX-_pointerDownX; - const signedDy=e.clientY-_pointerDownY; - _trackHorizontalSwipe(Math.abs(signedDx),Math.abs(signedDy)); - if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx); - }; - el.onpointercancel=_clearPointerDragState; - el.onpointerleave=()=>{ - if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState(); - }; - el.onpointerup=(e)=>{ - if(e.pointerType==='mouse' && e.button!==0) return; // ignore right/middle click - const wasDragging=_gestureState==='dragging'; + const _finishSessionGesture=(clientX,clientY,target,pointerType)=>{ + const wasDragging=_gestureState==='dragging'||_swipeTracking; _clearLongPressTimer(); - if(_renamingSid) return; - if(actions&&actions.contains(e.target)) return; - _pointerX=e.clientX; - _pointerY=e.clientY; + if(_renamingSid){_gestureState='idle';return false;} + if(_isSessionActionTarget(target)){_gestureState='idle';return false;} + _pointerX=clientX; + _pointerY=clientY; _commitSessionSwipe(); - if(_longPressMenuOpened||_gestureState==='committed'){e.stopPropagation();return;} - if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){ + if(_longPressMenuOpened){_gestureState='idle';return true;} + if(_gestureState==='committed') return true; + if(_sessionActionMenu&&!_sessionActionMenu.contains(target)){ closeSessionActionMenu(); - e.stopPropagation(); - return; + return true; } - 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(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; + return false; } _gestureState='idle'; const now=Date.now(); if(now-_lastTapTime<350){ - // Double-tap: rename clearTimeout(_tapTimer); _tapTimer=null; _lastTapTime=0; el.classList.remove('loading'); startRename(); - return; + 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; - if(e.pointerType!=='mouse') el.classList.add('loading'); + const delay=pointerType==='mouse'?0:300; + if(pointerType!=='mouse') el.classList.add('loading'); _tapTimer=setTimeout(async()=>{ _tapTimer=null; _lastTapTime=0; @@ -3878,6 +3882,32 @@ function renderSessionListFromCache(){ 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=_clearPointerDragState; + el.onpointerleave=()=>{ + if(_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)=>{ @@ -3895,40 +3925,22 @@ function renderSessionListFromCache(){ } }; el.addEventListener('touchstart',(e)=>{ + if(_isSessionActionTarget(e.target)) return; const touch=e.changedTouches&&e.changedTouches[0]; if(!touch) return; - _beginSessionGesture(touch.clientX,touch.clientY); - _gesturePointerType='touch'; + _beginSessionGesture(touch.clientX,touch.clientY,'touch'); _scheduleSessionLongPressMenu(); },{passive:true}); el.addEventListener('touchmove',(e)=>{ - if(_gestureState==='idle') return; const touch=e.changedTouches&&e.changedTouches[0]; if(!touch) return; - _pointerX=touch.clientX; - _pointerY=touch.clientY; - const signedDx=touch.clientX-_pointerDownX; - const signedDy=touch.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); - if(_swipeTracking) e.preventDefault(); + if(_updateSessionGesture(touch.clientX,touch.clientY)) e.preventDefault(); },{passive:false}); + el.addEventListener('touchcancel',_clearPointerDragState,{passive:true}); el.addEventListener('touchend',(e)=>{ - const wasDragging=_gestureState==='dragging'; - _clearLongPressTimer(); const touch=e.changedTouches&&e.changedTouches[0]; - if(touch){ - _pointerX=touch.clientX; - _pointerY=touch.clientY; - } - _commitSessionSwipe(); - if(wasDragging){ - if(_gestureState!=='committed') _clearDragTimer=setTimeout(()=>{_settleSessionSwipePaint();_clearDragTimer=null;},50); - } - if(_gestureState!=='committed') _gestureState='idle'; + if(!touch) return; + if(_finishSessionGesture(touch.clientX,touch.clientY,e.target,'touch')) e.stopPropagation(); },{passive:true}); return el; } diff --git a/static/style.css b/static/style.css index daf86032b3..8bbd8b6d48 100644 --- a/static/style.css +++ b/static/style.css @@ -664,8 +664,8 @@ 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) 18%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 60%,transparent);} - .session-item.swiping-left{background:color-mix(in srgb,var(--error) 16%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--error) 60%,transparent);} + .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; @@ -675,37 +675,47 @@ align-items:center; justify-content:center; gap:3px; - width:72px; - padding:0 10px; + width:var(--session-swipe-reveal,0px); + min-width:36px; + padding:0 8px; box-sizing:border-box; opacity:0; + overflow:hidden; transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18)); - transform-origin:center; pointer-events:none; + z-index:0; } .session-swipe-affordance-right{ left:0; - color:var(--warning); + color:#fff; + background:var(--warning); + border-radius:8px 6px 6px 8px; + transform-origin:left center; } .session-swipe-affordance-left{ right:0; - color:var(--error); + color:#fff; + background:var(--error); + border-radius:6px 8px 8px 6px; + transform-origin:right center; } .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-badge{ - width:30px; - height:30px; - border-radius:999px; + width:28px; + height:28px; + border-radius:0; display:flex; align-items:center; justify-content:center; color:#fff; + transform:scaleX(var(--session-swipe-icon-scale,1)); + transform-origin:center; } - .session-swipe-affordance-right .session-swipe-badge{background:var(--warning);} - .session-swipe-affordance-left .session-swipe-badge{background:var(--error);} + .session-swipe-badge svg{display:block;stroke-width:2.2;} .session-swipe-label{ max-width:58px; overflow:hidden; @@ -730,16 +740,16 @@ .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) 18%,var(--surface));} - .session-item.dragging.swiping-left{background:color-mix(in srgb,var(--error) 16%,var(--surface));} + .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){: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) 22%,var(--accent-bg));} - .session-item.active.swiping-left{background:color-mix(in srgb,var(--error) 20%,var(--accent-bg));} + .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.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;} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 8e373fd515..72cbd48481 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -37,7 +37,7 @@ def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): 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)=>{" in SESSIONS_JS + 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 @@ -46,35 +46,59 @@ def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): def test_open_session_menu_consumes_next_row_activation(): - assert "if(_sessionActionMenu&&!_sessionActionMenu.contains(e.target)){" in SESSIONS_JS + 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)=>{") - 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 + 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_archive_right_and_delete_left(): assert "_gesturePointerType!=='mouse'" in SESSIONS_JS assert "_swipeTracking=true" in SESSIONS_JS assert "const _trackHorizontalSwipe=(dx,dy)=>{" in SESSIONS_JS - assert "_swipeActionThreshold=144" in SESSIONS_JS + assert "_archiveSwipeActionThreshold=128" in SESSIONS_JS + assert "_deleteSwipeActionThreshold=128" in SESSIONS_JS assert "_committedSwipeDuration=_sessionPrefersReducedMotion()?0:420" in SESSIONS_JS assert "const _handleSessionSwipe=(signedDx,signedDy)=>{" in SESSIONS_JS - assert "if(_isSessionSwipeTarget()&&(_swipeTracking||Math.abs(signedDx)>Math.abs(signedDy))) _paintSessionSwipe(signedDx)" in SESSIONS_JS + assert "const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold;" in SESSIONS_JS + assert "if(Math.abs(signedDx){" in SESSIONS_JS assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS + assert "_updateSessionGesture(e.clientX,e.clientY);" 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 assert "if(signedDx>0){" in SESSIONS_JS assert "_archiveSession(s,!s.archived)" in SESSIONS_JS + assert "const completedAt=Date.now();" in SESSIONS_JS + assert "const remaining=_committedSwipeDuration-(Date.now()-completedAt);" in SESSIONS_JS assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS assert "showToast('Imported sessions cannot be deleted here.',3000);" in SESSIONS_JS assert "let _gestureState='idle';" in SESSIONS_JS assert "_gestureState='dragging';" in SESSIONS_JS assert "const _promoteSessionDrag=(dx,dy)=>{" in SESSIONS_JS - assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS assert "const _commitSessionSwipe=()=>{" in SESSIONS_JS assert "_commitSessionSwipe();" in SESSIONS_JS + assert "const wasDragging=_gestureState==='dragging'||_swipeTracking;" in SESSIONS_JS + assert "if(_gestureState==='committed'){" in SESSIONS_JS + assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 + assert "el.onpointercancel=_clearPointerDragState;" in SESSIONS_JS def test_session_swipes_show_visual_feedback_and_touch_load_clears(): @@ -84,11 +108,21 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "const overshoot=Math.max(0,Math.abs(rawOffset)-72)" in SESSIONS_JS assert "Math.sqrt(overshoot)*5" in SESSIONS_JS assert "el.style.setProperty('--session-swipe-offset',offset+'px')" in SESSIONS_JS + assert "const reveal=Math.min(132,Math.max(36,Math.abs(rawOffset)+24));" in SESSIONS_JS + assert "const iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130);" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-reveal',reveal+'px')" in SESSIONS_JS + assert "el.style.setProperty('--session-swipe-icon-scale',iconScale)" in SESSIONS_JS assert "const progress=Math.min(1,Math.abs(revealedOffset)/72)" in SESSIONS_JS assert "el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5))" in SESSIONS_JS assert "const _clearSessionSwipePaint=()=>{" in SESSIONS_JS + assert "el.style.removeProperty('--session-swipe-reveal');" in SESSIONS_JS + assert "el.style.removeProperty('--session-swipe-icon-scale');" in SESSIONS_JS + assert "el.style.removeProperty('height');" in SESSIONS_JS + assert "el.style.removeProperty('min-height');" in SESSIONS_JS + assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in SESSIONS_JS assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS assert "const _completeSessionSwipePaint=(signedDx)=>{" in SESSIONS_JS + assert "el.classList.remove('dragging');" in SESSIONS_JS assert "el.classList.add('swipe-committed')" in SESSIONS_JS assert "el.style.height=rect.height+'px'" in SESSIONS_JS assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in SESSIONS_JS @@ -96,26 +130,32 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS assert "const archived=await _archiveSession(s,!s.archived);" in SESSIONS_JS assert "if(!archived) _settleSessionSwipePaint();" in SESSIONS_JS - assert "await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration));" in SESSIONS_JS + assert "if(remaining>0) await new Promise(resolve=>setTimeout(resolve,remaining));" in SESSIONS_JS assert "async function deleteSession(sid, beforeDelete=null){" in SESSIONS_JS + assert "if(beforeDelete) await beforeDelete();" in SESSIONS_JS assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS assert ".session-item.swiping-right" in STYLE_CSS assert ".session-item.swiping-left" in STYLE_CSS assert "const _makeSessionSwipeAffordance=(side,icon,label)=>{" in SESSIONS_JS + assert "affordance.setAttribute('aria-hidden','true');" in SESSIONS_JS assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive'" in SESSIONS_JS assert "_makeSessionSwipeAffordance('left','trash-2'" in SESSIONS_JS assert ".session-swipe-affordance{" in STYLE_CSS assert "opacity:var(--session-swipe-progress,0)" in STYLE_CSS + assert "width:var(--session-swipe-reveal,0px)" in STYLE_CSS + assert ".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);}" in STYLE_CSS + assert ".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);}" in STYLE_CSS + assert "background:var(--warning)" in STYLE_CSS + assert "background:var(--error)" in STYLE_CSS assert ".session-item.swiping-right .session-swipe-affordance-right" in STYLE_CSS assert ".session-item.swiping-left .session-swipe-affordance-left" in STYLE_CSS assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" in STYLE_CSS assert ".session-swipe-badge{" in STYLE_CSS + assert "transform:scaleX(var(--session-swipe-icon-scale,1))" in STYLE_CSS assert ".session-swipe-label{" in STYLE_CSS assert "transform .5s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS assert ".session-item.dragging.swiping-right" in STYLE_CSS assert ".session-item.dragging.swiping-left" in STYLE_CSS - assert ".session-item.active.swiping-right" in STYLE_CSS - assert ".session-item.active.swiping-left" in STYLE_CSS assert ".session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;}" in STYLE_CSS assert ".session-item.swipe-committed" in STYLE_CSS assert ".session-item.swipe-removing{" in STYLE_CSS @@ -139,7 +179,7 @@ def test_session_removal_reflows_surviving_rows_smoothly(): assert "row.style.setProperty('--session-reflow-offset',delta+'px')" in SESSIONS_JS assert "row.classList.add('session-reflowing')" in SESSIONS_JS assert "row.style.setProperty('--session-reflow-offset','0px')" in SESSIONS_JS - assert SESSIONS_JS.count("const reflowPositions=_captureSessionReflowPositions();") >= 2 + assert "const reflowPositions=_captureSessionReflowPositions();" in SESSIONS_JS assert SESSIONS_JS.count("_pendingSessionReflowPositions=reflowPositions;") >= 2 assert "_playQueuedSessionReflowAnimation();" 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 @@ -148,9 +188,14 @@ def test_session_removal_reflows_surviving_rows_smoothly(): 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 "e.preventDefault()" 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(): From 6c964232311318517228ba947d3a762465bac60d Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 06:35:07 -0600 Subject: [PATCH 013/349] v4 effects save --- static/sessions.js | 2 +- static/style.css | 4 ++++ tests/test_session_touch_actions.py | 5 ++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 836f2c8c96..f1124c7026 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3652,7 +3652,7 @@ function renderSessionListFromCache(){ }; if(!readOnly){ el.append( - _makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?t('session_restore'):t('session_batch_archive')), + _makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive')), _makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')), ); } diff --git a/static/style.css b/static/style.css index 8bbd8b6d48..f92dda1664 100644 --- a/static/style.css +++ b/static/style.css @@ -750,6 +750,10 @@ .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{background: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;} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 72cbd48481..6add3283d5 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -138,7 +138,7 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert ".session-item.swiping-left" in STYLE_CSS assert "const _makeSessionSwipeAffordance=(side,icon,label)=>{" in SESSIONS_JS assert "affordance.setAttribute('aria-hidden','true');" in SESSIONS_JS - assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive'" in SESSIONS_JS + assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive'))" in SESSIONS_JS assert "_makeSessionSwipeAffordance('left','trash-2'" in SESSIONS_JS assert ".session-swipe-affordance{" in STYLE_CSS assert "opacity:var(--session-swipe-progress,0)" in STYLE_CSS @@ -146,6 +146,9 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert ".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);}" in STYLE_CSS assert ".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);}" in STYLE_CSS assert "background:var(--warning)" in STYLE_CSS + assert ".session-item.archived .session-swipe-affordance-right{background:var(--success);}" in STYLE_CSS + assert ".session-item.archived.dragging.swiping-right" in STYLE_CSS + assert ".session-item.active.archived.swiping-right{background:color-mix(in srgb,var(--success) 20%,var(--accent-bg));}" in STYLE_CSS assert "background:var(--error)" in STYLE_CSS assert ".session-item.swiping-right .session-swipe-affordance-right" in STYLE_CSS assert ".session-item.swiping-left .session-swipe-affordance-left" in STYLE_CSS From 03266c2644e3ec83d0497ad843c8da1e400385fb Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 06:52:33 -0600 Subject: [PATCH 014/349] fix: preserve touch swipe exit animations --- static/sessions.js | 14 ++++++++------ tests/test_session_touch_actions.py | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index f1124c7026..eb59da728f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3798,11 +3798,10 @@ function renderSessionListFromCache(){ if(!archived) _settleSessionSwipePaint(); },_committedSwipeDuration); }else if(_canSwipeDeleteSession()){ - _completeSessionSwipePaint(signedDx); - const completedAt=Date.now(); + el.classList.remove('dragging'); deleteSession(s.session_id,async()=>{ - const remaining=_committedSwipeDuration-(Date.now()-completedAt); - if(remaining>0) await new Promise(resolve=>setTimeout(resolve,remaining)); + _completeSessionSwipePaint(signedDx); + await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration)); }).then((deleted)=>{ if(!deleted) _settleSessionSwipePaint(); }); @@ -3900,9 +3899,12 @@ function renderSessionListFromCache(){ // faded until the next sidebar rerender clears their DOM nodes. _updateSessionGesture(e.clientX,e.clientY); }; - el.onpointercancel=_clearPointerDragState; + el.onpointercancel=(e)=>{ + if(e.pointerType==='touch') return; + _clearPointerDragState(); + }; el.onpointerleave=()=>{ - if(_gestureState!=='idle') _clearPointerDragState(); + if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState(); }; el.onpointerup=(e)=>{ if(e.pointerType==='touch') return; diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 6add3283d5..81e929b079 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -86,9 +86,15 @@ def test_session_swipes_archive_right_and_delete_left(): assert "_beginSessionGesture(touch.clientX,touch.clientY,'touch');" in SESSIONS_JS assert "if(signedDx>0){" in SESSIONS_JS assert "_archiveSession(s,!s.archived)" in SESSIONS_JS - assert "const completedAt=Date.now();" in SESSIONS_JS - assert "const remaining=_committedSwipeDuration-(Date.now()-completedAt);" in SESSIONS_JS assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS + delete_branch_start = SESSIONS_JS.find("}else if(_canSwipeDeleteSession()){") + delete_branch_end = SESSIONS_JS.find("}else if(typeof showToast", delete_branch_start) + assert delete_branch_start >= 0 and delete_branch_end > delete_branch_start + delete_branch = SESSIONS_JS[delete_branch_start:delete_branch_end] + delete_call_idx = delete_branch.find("deleteSession(s.session_id,async()=>{") + delete_complete_idx = delete_branch.find("_completeSessionSwipePaint(signedDx);") + assert delete_call_idx > 0 and delete_complete_idx > delete_call_idx + assert "const completedAt=Date.now();" not in delete_branch assert "showToast('Imported sessions cannot be deleted here.',3000);" in SESSIONS_JS assert "let _gestureState='idle';" in SESSIONS_JS assert "_gestureState='dragging';" in SESSIONS_JS @@ -98,7 +104,9 @@ def test_session_swipes_archive_right_and_delete_left(): assert "const wasDragging=_gestureState==='dragging'||_swipeTracking;" in SESSIONS_JS assert "if(_gestureState==='committed'){" in SESSIONS_JS assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 - assert "el.onpointercancel=_clearPointerDragState;" in SESSIONS_JS + assert "el.onpointercancel=(e)=>{" in SESSIONS_JS + assert "if(e.pointerType==='touch') return;" in SESSIONS_JS + assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS def test_session_swipes_show_visual_feedback_and_touch_load_clears(): @@ -130,7 +138,7 @@ def test_session_swipes_show_visual_feedback_and_touch_load_clears(): assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS assert "const archived=await _archiveSession(s,!s.archived);" in SESSIONS_JS assert "if(!archived) _settleSessionSwipePaint();" in SESSIONS_JS - assert "if(remaining>0) await new Promise(resolve=>setTimeout(resolve,remaining));" in SESSIONS_JS + assert "await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration));" in SESSIONS_JS assert "async function deleteSession(sid, beforeDelete=null){" in SESSIONS_JS assert "if(beforeDelete) await beforeDelete();" in SESSIONS_JS assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS From 1d8d0bfeb734bd6e1eca29a0f27b08066b95e914 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 07:30:41 -0600 Subject: [PATCH 015/349] optimize performance --- static/sessions.js | 149 +++++++++++++---------- static/style.css | 9 +- tests/test_profile_switch_ux.py | 25 ++-- tests/test_session_touch_actions.py | 178 +++++++++++++--------------- 4 files changed, 178 insertions(+), 183 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index eb59da728f..e7a2479acf 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -107,6 +107,10 @@ let _sessionListLastScrollAt = 0; let _pendingSessionListPayload = null; let _pendingSessionListApplyTimer = 0; const SESSION_LIST_INTERACTION_IDLE_MS = 700; +const SESSION_SWIPE_DURATION_MS = 420; +const SESSION_SWIPE_REFLOW_LEAD_MS = 180; +const SESSION_REFLOW_TIMEOUT_MS = 420; +const SESSION_LIST_FLIP_TIMEOUT_MS = 460; function _formatSessionModelWithGateway(s){ if(!s||!s.model)return''; @@ -1454,6 +1458,7 @@ const _lineageReportInflight = new Map(); let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; let _pendingSessionReflowPositions = null; +const _optimisticallyRemovedSessionIds = new Set(); function _captureSessionReflowPositions(){ const list=$('sessionList'); @@ -1465,23 +1470,31 @@ function _captureSessionReflowPositions(){ return positions; } -function _playQueuedSessionReflowAnimation(){ - const before=_pendingSessionReflowPositions; - _pendingSessionReflowPositions=null; +function _waitForSessionMotion(ms){ + return new Promise(resolve=>setTimeout(resolve,ms)); +} + +function _playSessionRowsReflowFromPositions(before, timeoutMs, prefersReducedMotion){ if(!before||!before.size) return; - const reduce=_sessionPrefersReducedMotion(); - if(reduce) 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'); - row.getBoundingClientRect(); - row.style.setProperty('--session-reflow-offset','0px'); + }); + list.getBoundingClientRect(); + movingRows.forEach(({row})=>{ let reflowCleared=false; const clearReflow=()=>{ if(reflowCleared) return; @@ -1494,10 +1507,20 @@ function _playQueuedSessionReflowAnimation(){ if(event.propertyName==='transform') clearReflow(); }; row.addEventListener('transitionend',onReflowEnd); - setTimeout(clearReflow,420); + row.style.removeProperty('transition'); + requestAnimationFrame(()=>requestAnimationFrame(()=>{ + if(!reflowCleared) row.style.setProperty('--session-reflow-offset','0px'); + })); + setTimeout(clearReflow,timeoutMs); }); } +function _playQueuedSessionReflowAnimation(){ + const before=_pendingSessionReflowPositions; + _pendingSessionReflowPositions=null; + _playSessionRowsReflowFromPositions(before,SESSION_REFLOW_TIMEOUT_MS,_sessionPrefersReducedMotion); +} + function _discardQueuedSessionReflowAnimation(){ _pendingSessionReflowPositions=null; } @@ -1812,18 +1835,20 @@ function _playSessionActionMenuEntrance(menu){ menu.classList.add('open-animated'); } -async function _archiveSession(session, archived=true){ +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; if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived; + if(renderHold) await renderHold; _pendingSessionReflowPositions=reflowPositions; await renderSessionList(); showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); return true; - }catch(err){_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;} + }catch(err){if(renderHold) await renderHold.catch(()=>{});_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;} } function _openSessionActionMenu(session, anchorEl){ @@ -2003,46 +2028,13 @@ 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); - }); + _playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion); } function _isOptimisticFirstTurnSessionRow(s){ @@ -2135,8 +2127,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); @@ -3039,7 +3034,7 @@ function renderSessionListFromCache(){ _sessionListRefreshAnimationPending=false; const enterAllAnimatedRows=animateRefresh&&_sessionListEnterAllAnimationPending; _sessionListEnterAllAnimationPending=false; - const flipBefore=animateRefresh?_captureSessionListFlipPositions():null; + const flipBefore=animateRefresh?_captureSessionReflowPositions():null; const listScrollTopBeforeRender=list.scrollTop||0; list.innerHTML=''; // Batch select bar (when in select mode) @@ -3680,7 +3675,8 @@ function renderSessionListFromCache(){ const _archiveSwipeActionThreshold=128; const _deleteSwipeActionThreshold=128; const _swipeCancelRatio=0.75; - const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:420; + const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS; + const _committedSwipeReflowDelay=Math.max(0,_committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS); const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} if(!_longPressMenuOpened) el.classList.remove('long-pressing'); @@ -3792,16 +3788,22 @@ function renderSessionListFromCache(){ _tapTimer=null; _lastTapTime=0; if(signedDx>0){ - _completeSessionSwipePaint(signedDx); - setTimeout(async()=>{ - const archived=await _archiveSession(s,!s.archived); - if(!archived) _settleSessionSwipePaint(); - },_committedSwipeDuration); + if(s.archived){ + _settleSessionSwipePaint(); + _archiveSession(s,false,()=>_waitForSessionMotion(_committedSwipeDuration)).then((restored)=>{ + if(!restored) _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 new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration)); + await _waitForSessionMotion(_committedSwipeReflowDelay); }).then((deleted)=>{ if(!deleted) _settleSessionSwipePaint(); }); @@ -4056,12 +4058,34 @@ async function deleteSession(sid, beforeDelete=null){ }); if(!ok)return false; const reflowPositions=_captureSessionReflowPositions(); - if(beforeDelete) await beforeDelete(); - let response=null; - try{ - response=await api('/api/session/delete',{method:'POST',body:JSON.stringify({session_id:sid})}); + 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){_pendingSessionReflowPositions=null;setStatus(`Delete failed: ${e.message}`);return false;} + return {response}; + }, error=>({error})); + if(beforeDeleteHold){ + await beforeDeleteHold; + _optimisticallyRemovedSessionIds.add(sid); + _allSessions=(_allSessions||[]).filter(item=>item&&item.session_id!==sid); + _pendingSessionReflowPositions=reflowPositions; + renderSessionListFromCache(); + 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(S.session&&S.session.session_id===sid){ S.session=null;S.messages=[];S.entries=[]; localStorage.removeItem('hermes-webui-session'); @@ -4080,8 +4104,11 @@ async function deleteSession(sid, beforeDelete=null){ } } showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted')); - _pendingSessionReflowPositions=reflowPositions; - await renderSessionList(); + if(optimisticRendered) void renderSessionList().finally(()=>_optimisticallyRemovedSessionIds.delete(sid)); + else{ + _pendingSessionReflowPositions=reflowPositions; + await renderSessionList(); + } return true; } diff --git a/static/style.css b/static/style.css index f92dda1664..6168f4df21 100644 --- a/static/style.css +++ b/static/style.css @@ -727,15 +727,10 @@ } .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} .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{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} + .session-item.swipe-committed, + .session-item.swipe-removing{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} .session-item.swipe-removing{ - min-height:0!important; - height:0!important; - margin-bottom:0; - padding-top:0; - padding-bottom:0; overflow:hidden; - transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease,height .36s cubic-bezier(.2,.8,.2,1),min-height .36s cubic-bezier(.2,.8,.2,1),margin-bottom .36s cubic-bezier(.2,.8,.2,1),padding-top .36s cubic-bezier(.2,.8,.2,1),padding-bottom .36s cubic-bezier(.2,.8,.2,1); } .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 */ diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index f7a9dbe1a1..79663922aa 100644 --- a/tests/test_profile_switch_ux.py +++ b/tests/test_profile_switch_ux.py @@ -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,22 +164,12 @@ 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 + assert "_playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion);" in fn def test_profile_refresh_flips_new_rows(self): assert "session-list-flip-enter" in self.JS @@ -188,7 +177,7 @@ def test_profile_refresh_flips_new_rows(self): 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) diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 81e929b079..e901bb622f 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -6,6 +6,18 @@ 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")] @@ -69,131 +81,103 @@ def test_open_session_menu_consumes_next_row_activation(): assert "if(_finishSessionGesture(e.clientX,e.clientY,e.target,e.pointerType)) e.stopPropagation();" in SESSIONS_JS[pointerup_idx:] -def test_session_swipes_archive_right_and_delete_left(): +def test_session_swipes_route_archive_restore_and_delete(): assert "_gesturePointerType!=='mouse'" in SESSIONS_JS assert "_swipeTracking=true" in SESSIONS_JS - assert "const _trackHorizontalSwipe=(dx,dy)=>{" in SESSIONS_JS assert "_archiveSwipeActionThreshold=128" in SESSIONS_JS assert "_deleteSwipeActionThreshold=128" in SESSIONS_JS - assert "_committedSwipeDuration=_sessionPrefersReducedMotion()?0:420" in SESSIONS_JS - assert "const _handleSessionSwipe=(signedDx,signedDy)=>{" in SESSIONS_JS + assert "const SESSION_SWIPE_DURATION_MS = 420;" in SESSIONS_JS + assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 180;" in SESSIONS_JS + assert "const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS;" in SESSIONS_JS + assert "const _committedSwipeReflowDelay=Math.max(0,_committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS);" in SESSIONS_JS + swipe_block = _sessions_block("const _handleSessionSwipe=(signedDx,signedDy)=>{", "const _commitSessionSwipe=()=>{") assert "const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold;" in SESSIONS_JS assert "if(Math.abs(signedDx){" in SESSIONS_JS assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS - assert "_updateSessionGesture(e.clientX,e.clientY);" 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 - assert "if(signedDx>0){" in SESSIONS_JS - assert "_archiveSession(s,!s.archived)" in SESSIONS_JS - assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS - delete_branch_start = SESSIONS_JS.find("}else if(_canSwipeDeleteSession()){") - delete_branch_end = SESSIONS_JS.find("}else if(typeof showToast", delete_branch_start) - assert delete_branch_start >= 0 and delete_branch_end > delete_branch_start - delete_branch = SESSIONS_JS[delete_branch_start:delete_branch_end] - delete_call_idx = delete_branch.find("deleteSession(s.session_id,async()=>{") - delete_complete_idx = delete_branch.find("_completeSessionSwipePaint(signedDx);") - assert delete_call_idx > 0 and delete_complete_idx > delete_call_idx - assert "const completedAt=Date.now();" not in delete_branch + 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 + 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 "_gestureState='dragging';" in SESSIONS_JS - assert "const _promoteSessionDrag=(dx,dy)=>{" in SESSIONS_JS assert "const _commitSessionSwipe=()=>{" in SESSIONS_JS - assert "_commitSessionSwipe();" in SESSIONS_JS - assert "const wasDragging=_gestureState==='dragging'||_swipeTracking;" in SESSIONS_JS assert "if(_gestureState==='committed'){" in SESSIONS_JS assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 - assert "el.onpointercancel=(e)=>{" in SESSIONS_JS - assert "if(e.pointerType==='touch') return;" in SESSIONS_JS assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS -def test_session_swipes_show_visual_feedback_and_touch_load_clears(): - assert "const _paintSessionSwipe=(signedDx)=>{" in SESSIONS_JS - assert "const rawOffset=signedDx*.55" in SESSIONS_JS - assert "const revealedOffset=Math.max(-72,Math.min(72,rawOffset))" in SESSIONS_JS - assert "const overshoot=Math.max(0,Math.abs(rawOffset)-72)" in SESSIONS_JS - assert "Math.sqrt(overshoot)*5" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-offset',offset+'px')" in SESSIONS_JS - assert "const reveal=Math.min(132,Math.max(36,Math.abs(rawOffset)+24));" in SESSIONS_JS - assert "const iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130);" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-reveal',reveal+'px')" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-icon-scale',iconScale)" in SESSIONS_JS - assert "const progress=Math.min(1,Math.abs(revealedOffset)/72)" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5))" in SESSIONS_JS - assert "const _clearSessionSwipePaint=()=>{" in SESSIONS_JS - assert "el.style.removeProperty('--session-swipe-reveal');" in SESSIONS_JS - assert "el.style.removeProperty('--session-swipe-icon-scale');" in SESSIONS_JS - assert "el.style.removeProperty('height');" in SESSIONS_JS - assert "el.style.removeProperty('min-height');" in SESSIONS_JS - assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in SESSIONS_JS - assert "const _settleSessionSwipePaint=()=>{" in SESSIONS_JS - assert "const _completeSessionSwipePaint=(signedDx)=>{" in SESSIONS_JS - assert "el.classList.remove('dragging');" in SESSIONS_JS - assert "el.classList.add('swipe-committed')" in SESSIONS_JS - assert "el.style.height=rect.height+'px'" in SESSIONS_JS - assert "requestAnimationFrame(()=>el.classList.add('swipe-removing'))" in SESSIONS_JS - assert "el.style.setProperty('--session-swipe-progress','0')" in SESSIONS_JS - assert "deleteSession(s.session_id,async()=>{" in SESSIONS_JS - assert "const archived=await _archiveSession(s,!s.archived);" in SESSIONS_JS - assert "if(!archived) _settleSessionSwipePaint();" in SESSIONS_JS - assert "await new Promise(resolve=>setTimeout(resolve,_committedSwipeDuration));" in SESSIONS_JS - assert "async function deleteSession(sid, beforeDelete=null){" in SESSIONS_JS - assert "if(beforeDelete) await beforeDelete();" 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 "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 ".session-item.swiping-right" in STYLE_CSS - assert ".session-item.swiping-left" in STYLE_CSS - assert "const _makeSessionSwipeAffordance=(side,icon,label)=>{" in SESSIONS_JS - assert "affordance.setAttribute('aria-hidden','true');" in SESSIONS_JS - assert "_makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive'))" in SESSIONS_JS - assert "_makeSessionSwipeAffordance('left','trash-2'" in SESSIONS_JS - assert ".session-swipe-affordance{" in STYLE_CSS - assert "opacity:var(--session-swipe-progress,0)" in STYLE_CSS - assert "width:var(--session-swipe-reveal,0px)" in STYLE_CSS - assert ".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);}" in STYLE_CSS - assert ".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);}" in STYLE_CSS - assert "background:var(--warning)" in STYLE_CSS - assert ".session-item.archived .session-swipe-affordance-right{background:var(--success);}" in STYLE_CSS - assert ".session-item.archived.dragging.swiping-right" in STYLE_CSS - assert ".session-item.active.archived.swiping-right{background:color-mix(in srgb,var(--success) 20%,var(--accent-bg));}" in STYLE_CSS - assert "background:var(--error)" in STYLE_CSS - assert ".session-item.swiping-right .session-swipe-affordance-right" in STYLE_CSS - assert ".session-item.swiping-left .session-swipe-affordance-left" in STYLE_CSS + assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear + assert ".session-item.swipe-committed,\n .session-item.swipe-removing{transition:" in STYLE_CSS assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" in STYLE_CSS - assert ".session-swipe-badge{" in STYLE_CSS - assert "transform:scaleX(var(--session-swipe-icon-scale,1))" in STYLE_CSS - assert ".session-swipe-label{" in STYLE_CSS - assert "transform .5s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS - assert ".session-item.dragging.swiping-right" in STYLE_CSS - assert ".session-item.dragging.swiping-left" in STYLE_CSS - assert ".session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;}" in STYLE_CSS - assert ".session-item.swipe-committed" in STYLE_CSS - assert ".session-item.swipe-removing{" in STYLE_CSS - assert "height .36s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS - assert "transform .42s cubic-bezier(.2,.8,.2,1)" in STYLE_CSS - assert ".session-item.swipe-committed .session-swipe-affordance{transition:opacity .18s ease,transform .18s ease;}" in STYLE_CSS - assert ".session-item.long-pressing" in STYLE_CSS - assert "@keyframes session-long-press" 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:translateX(var(--session-swipe-offset,0))" in STYLE_CSS - assert "finally{" in SESSIONS_JS - assert "el.classList.remove('loading');" in SESSIONS_JS def test_session_removal_reflows_surviving_rows_smoothly(): assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS - assert "function _captureSessionReflowPositions(){" in SESSIONS_JS - assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in SESSIONS_JS - assert "function _playQueuedSessionReflowAnimation(){" in SESSIONS_JS - assert "function _sessionPrefersReducedMotion(){" in SESSIONS_JS - assert "const delta=oldTop-row.getBoundingClientRect().top;" in SESSIONS_JS - assert "row.style.setProperty('--session-reflow-offset',delta+'px')" in SESSIONS_JS - assert "row.classList.add('session-reflowing')" in SESSIONS_JS - assert "row.style.setProperty('--session-reflow-offset','0px')" in SESSIONS_JS - assert "const reflowPositions=_captureSessionReflowPositions();" in SESSIONS_JS + assert "const _optimisticallyRemovedSessionIds = new Set();" in SESSIONS_JS + capture = _sessions_block("function _captureSessionReflowPositions(){", "function _waitForSessionMotion") + helper = _sessions_block("function _playSessionRowsReflowFromPositions", "function _playQueuedSessionReflowAnimation") + 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 helper.count("getBoundingClientRect()") == 2 + 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 "_playSessionRowsReflowFromPositions(before,SESSION_REFLOW_TIMEOUT_MS,_sessionPrefersReducedMotion);" in SESSIONS_JS + assert "_playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion);" 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 "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 "_playQueuedSessionReflowAnimation();" 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 + 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_filter = delete_body.find("_allSessions=(_allSessions||[]).filter") + optimistic_render = delete_body.find("renderSessionListFromCache();", optimistic_filter) + 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));") + assert delete_body.find("const reflowPositions=_captureSessionReflowPositions();") < hold_start < delete_request < hold_await < optimistic_set < optimistic_filter < optimistic_render < response_await < rollback < final_render + assert "}, error=>({error}));" in delete_body def test_ios_touch_events_drive_session_swipes(): From a277bbd42c80b1b77ff600f420f6525d1665b7e2 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 07:41:08 -0600 Subject: [PATCH 016/349] refactor --- static/sessions.js | 44 ++++++++--------------------- static/style.css | 6 ++-- tests/test_profile_switch_ux.py | 29 ++++++------------- tests/test_session_touch_actions.py | 18 ++++++------ 4 files changed, 32 insertions(+), 65 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index e7a2479acf..435e481ae7 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1515,16 +1515,6 @@ function _playSessionRowsReflowFromPositions(before, timeoutMs, prefersReducedMo }); } -function _playQueuedSessionReflowAnimation(){ - const before=_pendingSessionReflowPositions; - _pendingSessionReflowPositions=null; - _playSessionRowsReflowFromPositions(before,SESSION_REFLOW_TIMEOUT_MS,_sessionPrefersReducedMotion); -} - -function _discardQueuedSessionReflowAnimation(){ - _pendingSessionReflowPositions=null; -} - function _sessionPrefersReducedMotion(){ return Boolean(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches); } @@ -2028,15 +2018,6 @@ function animateNextSessionListRefresh(options={}){ if(options&&options.enterAll) _sessionListEnterAllAnimationPending = true; } -function _sessionListPrefersReducedMotion(){ - try{return window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;} - catch(_){return false;} -} - -function _playSessionListFlipAnimation(before){ - _playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion); -} - function _isOptimisticFirstTurnSessionRow(s){ if(!s||!s.session_id||s.archived) return false; const messageCount=Number(s.message_count||0); @@ -3035,6 +3016,8 @@ function renderSessionListFromCache(){ const enterAllAnimatedRows=animateRefresh&&_sessionListEnterAllAnimationPending; _sessionListEnterAllAnimationPending=false; 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) @@ -3273,15 +3256,12 @@ function renderSessionListFromCache(){ toggleBtn.onclick=(e)=>{e.stopPropagation();toggleSessionSelectMode();}; list.appendChild(toggleBtn); } - if(animateRefresh){ - // Refresh FLIP and queued archive/delete reflow both drive - // --session-reflow-offset. Refresh wins so one render has one transform - // writer. - _discardQueuedSessionReflowAnimation(); - _playSessionListFlipAnimation(flipBefore); - }else{ - _playQueuedSessionReflowAnimation(); - } + // 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'); @@ -3675,8 +3655,6 @@ function renderSessionListFromCache(){ const _archiveSwipeActionThreshold=128; const _deleteSwipeActionThreshold=128; const _swipeCancelRatio=0.75; - const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS; - const _committedSwipeReflowDelay=Math.max(0,_committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS); const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} if(!_longPressMenuOpened) el.classList.remove('long-pressing'); @@ -3790,12 +3768,12 @@ function renderSessionListFromCache(){ if(signedDx>0){ if(s.archived){ _settleSessionSwipePaint(); - _archiveSession(s,false,()=>_waitForSessionMotion(_committedSwipeDuration)).then((restored)=>{ + _archiveSession(s,false,()=>_waitForSessionMotion(committedSwipeDuration)).then((restored)=>{ if(!restored) _settleSessionSwipePaint(); }); }else{ _completeSessionSwipePaint(signedDx); - _archiveSession(s,true,()=>_waitForSessionMotion(_committedSwipeReflowDelay)).then((archived)=>{ + _archiveSession(s,true,()=>_waitForSessionMotion(committedSwipeReflowDelay)).then((archived)=>{ if(!archived) _settleSessionSwipePaint(); }); } @@ -3803,7 +3781,7 @@ function renderSessionListFromCache(){ el.classList.remove('dragging'); deleteSession(s.session_id,async()=>{ _completeSessionSwipePaint(signedDx); - await _waitForSessionMotion(_committedSwipeReflowDelay); + await _waitForSessionMotion(committedSwipeReflowDelay); }).then((deleted)=>{ if(!deleted) _settleSessionSwipePaint(); }); diff --git a/static/style.css b/static/style.css index 6168f4df21..efe39142d0 100644 --- a/static/style.css +++ b/static/style.css @@ -725,10 +725,10 @@ font-weight:600; line-height:1; } - .session-item.dragging{transition:background .15s,color .15s,box-shadow .15s ease;} + .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 .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;} + .session-item.swipe-removing{transition:background .15s,color .15s,transform .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;} .session-item.swipe-removing{ overflow:hidden; } @@ -822,7 +822,7 @@ .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.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;} + .session-item,.session-item.dragging,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;} .session-item.long-pressing{animation:none;} .session-item.session-list-flip-enter{animation:none;} } diff --git a/tests/test_profile_switch_ux.py b/tests/test_profile_switch_ux.py index 79663922aa..9dbe659776 100644 --- a/tests/test_profile_switch_ux.py +++ b/tests/test_profile_switch_ux.py @@ -164,44 +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_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 "_playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion);" 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?_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): - assert "function _discardQueuedSessionReflowAnimation(){" in self.JS - start = self.JS.index("if(animateRefresh){") + 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 "_discardQueuedSessionReflowAnimation();" in block - assert "_playSessionListFlipAnimation(flipBefore);" in block - assert "_playQueuedSessionReflowAnimation();" in block - assert block.index("_discardQueuedSessionReflowAnimation();") < block.index("_playSessionListFlipAnimation(flipBefore);") - assert block.index("_playSessionListFlipAnimation(flipBefore);") < block.index("_playQueuedSessionReflowAnimation();") + 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 diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index e901bb622f..bb061463a3 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -37,7 +37,7 @@ def test_session_menu_has_subtle_open_animation(): 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 - assert ".session-item,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;}" in STYLE_CSS + assert ".session-item,.session-item.dragging,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;}" in STYLE_CSS assert ".session-item.long-pressing{animation:none;}" in STYLE_CSS @@ -88,8 +88,8 @@ def test_session_swipes_route_archive_restore_and_delete(): assert "_deleteSwipeActionThreshold=128" in SESSIONS_JS assert "const SESSION_SWIPE_DURATION_MS = 420;" in SESSIONS_JS assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 180;" in SESSIONS_JS - assert "const _committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS;" in SESSIONS_JS - assert "const _committedSwipeReflowDelay=Math.max(0,_committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS);" in SESSIONS_JS + assert "const committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS;" in SESSIONS_JS + assert "const committedSwipeReflowDelay=Math.max(0,committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS);" in SESSIONS_JS swipe_block = _sessions_block("const _handleSessionSwipe=(signedDx,signedDy)=>{", "const _commitSessionSwipe=()=>{") assert "const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold;" in SESSIONS_JS assert "if(Math.abs(signedDx)_waitForSessionMotion(_committedSwipeDuration))" in restore_branch - assert archive_branch.find("_completeSessionSwipePaint(signedDx);") < archive_branch.find("_archiveSession(s,true,()=>_waitForSessionMotion(_committedSwipeReflowDelay))") + assert "_archiveSession(s,false,()=>_waitForSessionMotion(committedSwipeDuration))" in restore_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 @@ -144,7 +144,7 @@ def test_session_removal_reflows_surviving_rows_smoothly(): assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS assert "const _optimisticallyRemovedSessionIds = new Set();" in SESSIONS_JS capture = _sessions_block("function _captureSessionReflowPositions(){", "function _waitForSessionMotion") - helper = _sessions_block("function _playSessionRowsReflowFromPositions", "function _playQueuedSessionReflowAnimation") + 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 @@ -154,14 +154,14 @@ def test_session_removal_reflows_surviving_rows_smoothly(): assert "row.classList.add('session-reflowing')" in helper assert "requestAnimationFrame(()=>requestAnimationFrame(()=>{" in helper assert SESSIONS_JS.count("_pendingSessionReflowPositions=reflowPositions;") >= 2 - assert "_playSessionRowsReflowFromPositions(before,SESSION_REFLOW_TIMEOUT_MS,_sessionPrefersReducedMotion);" in SESSIONS_JS - assert "_playSessionRowsReflowFromPositions(before,SESSION_LIST_FLIP_TIMEOUT_MS,_sessionListPrefersReducedMotion);" in SESSIONS_JS + 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 "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 "_playQueuedSessionReflowAnimation();" 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 delete_start = SESSIONS_JS.find("async function deleteSession(sid, beforeDelete=null){") delete_end = SESSIONS_JS.find("// ── Project helpers", delete_start) From c4fffff2057d9c6cb5d2ef74a4d630412d09dc74 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 07:52:42 -0600 Subject: [PATCH 017/349] refine archive/restore animations --- static/sessions.js | 21 +++++++++++++++++++-- static/style.css | 10 ++++++---- tests/test_session_touch_actions.py | 23 ++++++++++++++++++----- 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 435e481ae7..decb50ec0a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -107,8 +107,8 @@ let _sessionListLastScrollAt = 0; let _pendingSessionListPayload = null; let _pendingSessionListApplyTimer = 0; const SESSION_LIST_INTERACTION_IDLE_MS = 700; -const SESSION_SWIPE_DURATION_MS = 420; -const SESSION_SWIPE_REFLOW_LEAD_MS = 180; +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; @@ -1459,6 +1459,7 @@ let _lineageReportCacheGeneration = 0; let _sessionVisibleSidebarIds = []; let _pendingSessionReflowPositions = null; const _optimisticallyRemovedSessionIds = new Set(); +const _sessionSwipeReturnOffsets = new Map(); function _captureSessionReflowPositions(){ const list=$('sessionList'); @@ -1834,6 +1835,7 @@ async function _archiveSession(session, archived=true, beforeListRender=null){ session.archived=archived; if(S.session&&S.session.session_id===session.session_id) S.session.archived=archived; if(renderHold) await renderHold; + if(_showArchived&&!_sessionPrefersReducedMotion()) _sessionSwipeReturnOffsets.set(session.session_id,'0px'); _pendingSessionReflowPositions=reflowPositions; await renderSessionList(); showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); @@ -3272,6 +3274,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'); } @@ -3771,6 +3783,11 @@ function renderSessionListFromCache(){ _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)=>{ diff --git a/static/style.css b/static/style.css index efe39142d0..b3a3fd0c6e 100644 --- a/static/style.css +++ b/static/style.css @@ -654,7 +654,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,transform .5s cubic-bezier(.2,.8,.2,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:translateX(var(--session-swipe-offset,0)) 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;} @media (hover:hover){.session-item:hover{background:var(--hover-bg);color:var(--text);}} .session-item.loading{background:var(--hover-bg);color:var(--text);} @@ -681,7 +681,7 @@ box-sizing:border-box; opacity:0; overflow:hidden; - transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18)); + transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0) scale(calc(.82 + var(--session-swipe-progress,0) * .18)); pointer-events:none; z-index:0; } @@ -728,10 +728,11 @@ .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 .42s cubic-bezier(.2,.8,.2,1),box-shadow .15s ease;will-change:transform;} + .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);} @@ -823,13 +824,14 @@ @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{animation: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);}} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index bb061463a3..0e9e36e4a9 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -38,7 +38,7 @@ def test_session_menu_has_subtle_open_animation(): assert "@media (prefers-reduced-motion:reduce)" in STYLE_CSS assert ".session-action-menu{animation:none;will-change:auto;}" in STYLE_CSS assert ".session-item,.session-item.dragging,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;}" in STYLE_CSS - assert ".session-item.long-pressing{animation:none;}" in STYLE_CSS + assert ".session-item.long-pressing,.session-item.session-swipe-returning{animation:none;}" in STYLE_CSS def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): @@ -86,8 +86,8 @@ def test_session_swipes_route_archive_restore_and_delete(): assert "_swipeTracking=true" in SESSIONS_JS assert "_archiveSwipeActionThreshold=128" in SESSIONS_JS assert "_deleteSwipeActionThreshold=128" in SESSIONS_JS - assert "const SESSION_SWIPE_DURATION_MS = 420;" in SESSIONS_JS - assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 180;" in SESSIONS_JS + assert "const SESSION_SWIPE_DURATION_MS = 500;" in SESSIONS_JS + assert "const SESSION_SWIPE_REFLOW_LEAD_MS = 220;" in SESSIONS_JS assert "const committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS;" in SESSIONS_JS assert "const committedSwipeReflowDelay=Math.max(0,committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS);" in SESSIONS_JS swipe_block = _sessions_block("const _handleSessionSwipe=(signedDx,signedDy)=>{", "const _commitSessionSwipe=()=>{") @@ -106,6 +106,12 @@ def test_session_swipes_route_archive_restore_and_delete(): 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 @@ -129,7 +135,8 @@ def test_session_swipe_paint_uses_transform_only_exit(): assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear assert ".session-item.swipe-committed,\n .session-item.swipe-removing{transition:" in STYLE_CSS - assert "transform:translateX(calc(-1 * var(--session-swipe-offset,0px))) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" in STYLE_CSS + assert "transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease" in STYLE_CSS + assert "transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" 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 @@ -137,12 +144,13 @@ def test_session_swipe_paint_uses_transform_only_exit(): 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:translateX(var(--session-swipe-offset,0))" in STYLE_CSS + assert "transform:translate3d(var(--session-swipe-offset,0),var(--session-reflow-offset,0),0)" 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 @@ -163,6 +171,11 @@ def test_session_removal_reflows_surviving_rows_smoothly(): 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 + assert "@keyframes sessionSwipeReturn" in STYLE_CSS + assert ".session-item.session-swipe-returning{animation:sessionSwipeReturn .5s cubic-bezier(.22,.61,.36,1) both;will-change:transform,opacity;}" in STYLE_CSS 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 From 13fa0de8913215f80337e79d5cbaa307efe58d06 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 07:59:16 -0600 Subject: [PATCH 018/349] refined iOS enter animations --- static/sessions.js | 2 +- static/style.css | 5 ++--- tests/test_session_touch_actions.py | 5 ++++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index decb50ec0a..fbde5c7bba 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3735,7 +3735,7 @@ function renderSessionListFromCache(){ 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.min(132,Math.max(36,Math.abs(rawOffset)+24)); + const reveal=Math.abs(offset); const iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130); el.style.setProperty('--session-swipe-offset',offset+'px'); el.style.setProperty('--session-swipe-reveal',reveal+'px'); diff --git a/static/style.css b/static/style.css index b3a3fd0c6e..1e1536f00c 100644 --- a/static/style.css +++ b/static/style.css @@ -676,12 +676,11 @@ justify-content:center; gap:3px; width:var(--session-swipe-reveal,0px); - min-width:36px; padding:0 8px; box-sizing:border-box; opacity:0; overflow:hidden; - transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0) scale(calc(.82 + var(--session-swipe-progress,0) * .18)); + transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0); pointer-events:none; z-index:0; } @@ -712,7 +711,7 @@ align-items:center; justify-content:center; color:#fff; - transform:scaleX(var(--session-swipe-icon-scale,1)); + transform:scale(var(--session-swipe-icon-scale,1)); transform-origin:center; } .session-swipe-badge svg{display:block;stroke-width:2.2;} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 0e9e36e4a9..f42f835316 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -129,6 +129,7 @@ def test_session_swipe_paint_uses_transform_only_exit(): assert "--session-swipe-offset" in paint assert "--session-swipe-reveal" in paint assert "--session-swipe-progress" in paint + assert "const reveal=Math.abs(offset);" 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 @@ -136,7 +137,9 @@ def test_session_swipe_paint_uses_transform_only_exit(): assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear assert ".session-item.swipe-committed,\n .session-item.swipe-removing{transition:" in STYLE_CSS assert "transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease" in STYLE_CSS - assert "transform:translate3d(calc(-1 * var(--session-swipe-offset,0px)),0,0) scale(calc(.82 + var(--session-swipe-progress,0) * .18))" 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))" in STYLE_CSS + assert "min-width:36px" not in STYLE_CSS[STYLE_CSS.find(".session-swipe-affordance{"):STYLE_CSS.find(".session-swipe-affordance-right{")] 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 From ad88c92155db5dbdbdd8886206f2a7c160c3bd1a Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Thu, 21 May 2026 08:25:04 -0600 Subject: [PATCH 019/349] clean up & refactor --- static/sessions.js | 55 ++++++++++--------- tests/conftest.py | 9 +++ .../test_issue2454_active_session_spinner.py | 6 +- .../test_issue856_pinned_indicator_layout.py | 2 +- tests/test_session_touch_actions.py | 31 +++-------- 5 files changed, 51 insertions(+), 52 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index fbde5c7bba..27a56b9aa0 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -111,6 +111,10 @@ 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''; @@ -1517,7 +1521,25 @@ function _playSessionRowsReflowFromPositions(before, timeoutMs, prefersReducedMo } function _sessionPrefersReducedMotion(){ - return Boolean(window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches); + 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 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; + affordance.append(badge,text); + return affordance; } const SESSION_VIRTUAL_ROW_HEIGHT = 52; const SESSION_VIRTUAL_BUFFER_ROWS = 12; @@ -3616,6 +3638,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; @@ -3624,22 +3647,9 @@ function renderSessionListFromCache(){ _openSessionActionMenu(s, actions||el); }; - const _makeSessionSwipeAffordance=(side,icon,label)=>{ - const affordance=document.createElement('div'); - affordance.className='session-swipe-affordance session-swipe-affordance-'+side; - affordance.setAttribute('aria-hidden','true'); - 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; - affordance.append(badge,text); - return affordance; - }; if(!readOnly){ el.append( - _makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive')), + _makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?t('session_restore'):t('session_batch_archive')), _makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')), ); } @@ -3663,10 +3673,6 @@ function renderSessionListFromCache(){ let _pointerX=0; let _pointerY=0; let _gesturePointerType=''; - const _longPressDelay=400; - const _archiveSwipeActionThreshold=128; - const _deleteSwipeActionThreshold=128; - const _swipeCancelRatio=0.75; const _clearLongPressTimer=()=>{ if(_longPressTimer){clearTimeout(_longPressTimer);_longPressTimer=null;} if(!_longPressMenuOpened) el.classList.remove('long-pressing'); @@ -3695,7 +3701,7 @@ function renderSessionListFromCache(){ _tapTimer=null; _lastTapTime=0; _openSessionActionMenu(s, el); - },_longPressDelay); + },SESSION_LONG_PRESS_DELAY_MS); }; const _isSessionSwipeTarget=()=>{ return _gesturePointerType!=='mouse'&&!readOnly&&!_renamingSid&&!_sessionSelectMode; @@ -3769,9 +3775,9 @@ function renderSessionListFromCache(){ }; const _handleSessionSwipe=(signedDx,signedDy)=>{ if(_gestureState==='committed'||!_isSessionSwipeTarget()) return false; - const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold; + const actionThreshold=signedDx>0?SESSION_ARCHIVE_SWIPE_THRESHOLD_PX:SESSION_DELETE_SWIPE_THRESHOLD_PX; if(Math.abs(signedDx)Math.abs(signedDx)*_swipeCancelRatio) return false; + if(Math.abs(signedDy)>Math.abs(signedDx)*SESSION_SWIPE_CANCEL_RATIO) return false; _gestureState='committed'; _clearLongPressTimer(); clearTimeout(_tapTimer); @@ -3918,11 +3924,6 @@ 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)=>{ if(_isSessionActionTarget(e.target)) return; const touch=e.changedTouches&&e.changedTouches[0]; diff --git a/tests/conftest.py b/tests/conftest.py index 66cf0102b8..84bc88d14c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,6 +74,15 @@ def _auto_state_dir_name(repo_root) -> str: # 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(): diff --git a/tests/test_issue2454_active_session_spinner.py b/tests/test_issue2454_active_session_spinner.py index 22e7d59d40..21e490c357 100644 --- a/tests/test_issue2454_active_session_spinner.py +++ b/tests/test_issue2454_active_session_spinner.py @@ -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" ) diff --git a/tests/test_issue856_pinned_indicator_layout.py b/tests/test_issue856_pinned_indicator_layout.py index 09785d40fb..b7c13056da 100644 --- a/tests/test_issue856_pinned_indicator_layout.py +++ b/tests/test_issue856_pinned_indicator_layout.py @@ -120,7 +120,7 @@ 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 _gestureState='idle';" in SESSIONS_JS assert "_gestureState='pressing';" in SESSIONS_JS - assert "if(_gestureState==='idle') return;" 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 diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index f42f835316..6f896ef32e 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -24,25 +24,17 @@ def test_session_menu_uses_viewport_height_not_fixed_scroll_cap(): assert "max-height:320px" not in session_menu -def test_session_menu_has_subtle_open_animation(): +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 "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:450, 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 .45s 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 - assert ".session-item,.session-item.dragging,.session-item.session-reflowing,.session-item.swipe-committed,.session-item.swipe-removing{transition:none;}" in STYLE_CSS - assert ".session-item.long-pressing,.session-item.session-swipe-returning{animation:none;}" in STYLE_CSS def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): - assert "_longPressDelay=400" in SESSIONS_JS + 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 @@ -58,6 +50,10 @@ def test_mobile_session_menu_opens_from_long_press_and_hides_dots(): 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 @@ -84,14 +80,12 @@ def test_open_session_menu_consumes_next_row_activation(): def test_session_swipes_route_archive_restore_and_delete(): assert "_gesturePointerType!=='mouse'" in SESSIONS_JS assert "_swipeTracking=true" in SESSIONS_JS - assert "_archiveSwipeActionThreshold=128" in SESSIONS_JS - assert "_deleteSwipeActionThreshold=128" 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 - assert "const committedSwipeDuration=_sessionPrefersReducedMotion()?0:SESSION_SWIPE_DURATION_MS;" in SESSIONS_JS - assert "const committedSwipeReflowDelay=Math.max(0,committedSwipeDuration-SESSION_SWIPE_REFLOW_LEAD_MS);" in SESSIONS_JS swipe_block = _sessions_block("const _handleSessionSwipe=(signedDx,signedDy)=>{", "const _commitSessionSwipe=()=>{") - assert "const actionThreshold=signedDx>0?_archiveSwipeActionThreshold:_deleteSwipeActionThreshold;" in SESSIONS_JS + assert "const actionThreshold=signedDx>0?SESSION_ARCHIVE_SWIPE_THRESHOLD_PX:SESSION_DELETE_SWIPE_THRESHOLD_PX;" in SESSIONS_JS assert "if(Math.abs(signedDx){" in SESSIONS_JS assert "if(_isSessionSwipeTarget()&&(_swipeTracking||dx>dy)) _paintSessionSwipe(signedDx)" in SESSIONS_JS @@ -116,8 +110,6 @@ def test_session_swipes_route_archive_restore_and_delete(): 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 "const _commitSessionSwipe=()=>{" in SESSIONS_JS - assert "if(_gestureState==='committed'){" in SESSIONS_JS assert SESSIONS_JS.count("if(e.pointerType==='touch') return;") >= 3 assert "if(_gesturePointerType==='mouse'&&_gestureState!=='idle') _clearPointerDragState();" in SESSIONS_JS @@ -136,10 +128,8 @@ def test_session_swipe_paint_uses_transform_only_exit(): assert "requestAnimationFrame(()=>requestAnimationFrame(_clearSessionSwipePaint))" in SESSIONS_JS assert "el.classList.remove('swiping-right','swiping-left','swipe-committed','swipe-removing')" in clear assert ".session-item.swipe-committed,\n .session-item.swipe-removing{transition:" in STYLE_CSS - assert "transition:background .15s,color .15s,transform .5s cubic-bezier(.22,.61,.36,1),box-shadow .15s ease" 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))" in STYLE_CSS - assert "min-width:36px" not in STYLE_CSS[STYLE_CSS.find(".session-swipe-affordance{"):STYLE_CSS.find(".session-swipe-affordance-right{")] 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 @@ -160,7 +150,6 @@ def test_session_removal_reflows_surviving_rows_smoothly(): assert "const delta=oldTop-row.getBoundingClientRect().top;" in helper assert "const movingRows=[];" in helper assert "list.getBoundingClientRect();" in helper - assert helper.count("getBoundingClientRect()") == 2 assert "row.style.transition='none';" in helper assert "row.classList.add('session-reflowing')" in helper assert "requestAnimationFrame(()=>requestAnimationFrame(()=>{" in helper @@ -177,8 +166,6 @@ def test_session_removal_reflows_surviving_rows_smoothly(): 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 - assert "@keyframes sessionSwipeReturn" in STYLE_CSS - assert ".session-item.session-swipe-returning{animation:sessionSwipeReturn .5s cubic-bezier(.22,.61,.36,1) both;will-change:transform,opacity;}" in STYLE_CSS 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 From 59ffe4fca5c3eb21e9df90ab24f0de4d8cd18c76 Mon Sep 17 00:00:00 2001 From: fxd-jason Date: Thu, 21 May 2026 21:11:56 +0800 Subject: [PATCH 020/349] fix: geist-contrast skin composer UI improvements - Light mode: override white user-bubble-text so textarea text is black (#111) - Remove scrollbar from textarea (scrollbar-width:none + webkit) - Remove double border on focus: split composer-box:focus-within from textarea:focus to prevent stacking box-shadows - Remove composer-box border (border:none) to eliminate double-border ring --- static/style.css | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/static/style.css b/static/style.css index 9ddc7cc02b..d7c62b643b 100644 --- a/static/style.css +++ b/static/style.css @@ -347,7 +347,7 @@ :root[data-skin="geist-contrast"] .tool-card, :root[data-skin="geist-contrast"] .msg-body pre, :root[data-skin="geist-contrast"] .preview-md pre, - :root[data-skin="geist-contrast"] .composer-box{background:var(--surface)!important;border-color:var(--border)!important;box-shadow:none!important;} + :root[data-skin="geist-contrast"] .composer-box{background:var(--surface)!important;border:none!important;box-shadow:none!important;} :root[data-skin="geist-contrast"] .session-item, :root[data-skin="geist-contrast"] .nav-tab, :root[data-skin="geist-contrast"] .rail-btn, @@ -440,10 +440,10 @@ :root[data-skin="geist-contrast"] button.send-btn:disabled{background:var(--surface-subtle)!important;border-color:var(--border)!important;color:var(--muted)!important;opacity:1!important;} :root.dark[data-skin="geist-contrast"] button.send-btn:disabled svg, :root.dark[data-skin="geist-contrast"] button.send-btn:disabled [data-lucide]{color:var(--muted)!important;stroke:var(--muted)!important;} + :root[data-skin="geist-contrast"] .composer-box:focus-within{border-color:transparent!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;} :root[data-skin="geist-contrast"] input:focus, :root[data-skin="geist-contrast"] textarea:focus, - :root[data-skin="geist-contrast"] select:focus, - :root[data-skin="geist-contrast"] .composer-box:focus-within, + :root[data-skin="geist-contrast"] select:focus{border-color:var(--accent)!important;box-shadow:none!important;outline:none!important;} :root[data-skin="geist-contrast"] .app-dialog-input:focus, :root[data-skin="geist-contrast"] .sidebar-search input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;} :root[data-skin="geist-contrast"] .logo, @@ -463,6 +463,12 @@ :root[data-skin="geist-contrast"] .sidebar-date-header.pinned{color:var(--accent-text)!important;} :root[data-skin="geist-contrast"]::-webkit-scrollbar-thumb{background:var(--border2)!important;} :root[data-skin="geist-contrast"] ::selection{background:var(--accent-bg-strong);color:var(--strong);} + /* ── Geist Contrast: composer fixes ── */ + /* Light mode: override white user-bubble-text so textarea text is black */ + :root[data-skin="geist-contrast"]:not(.dark){--user-bubble-text:#111111;} + /* Remove scrollbar from textarea */ + :root[data-skin="geist-contrast"] textarea#msg{scrollbar-width:none;overflow-y:auto;} + :root[data-skin="geist-contrast"] textarea#msg::-webkit-scrollbar{display:none;} /* #594: app-dialog light mode overrides — base styles use hardcoded dark gradients */ :root:not(.dark) .app-dialog{ From e2bff58964730e687f62c85d28547c34705ae9b4 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Sun, 24 May 2026 17:58:26 -0600 Subject: [PATCH 021/349] Refine iOS-style session swipe actions --- static/sessions.js | 14 +++++++---- static/style.css | 22 ++++++++++------- tests/test_session_touch_actions.py | 37 +++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/static/sessions.js b/static/sessions.js index 9ecaa6f590..223dd981e6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1908,12 +1908,15 @@ async function _archiveSession(session, archived=true, 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; - await renderSessionList(); - showToast(session.archived?_sessionArchiveToast(response,session):t('session_restored')); + renderSessionListFromCache(); + void renderSessionList(); return true; }catch(err){if(renderHold) await renderHold.catch(()=>{});_pendingSessionReflowPositions=null;showToast(t('session_archive_failed')+err.message);return false;} } @@ -3767,7 +3770,7 @@ function renderSessionListFromCache(){ if(!readOnly){ el.append( - _makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?t('session_restore'):t('session_batch_archive')), + _makeSessionSwipeAffordance('right',s.archived?'undo':'archive',s.archived?'Restore':t('session_batch_archive')), _makeSessionSwipeAffordance('left','trash-2',t('session_batch_delete')), ); } @@ -3860,10 +3863,12 @@ function renderSessionListFromCache(){ 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 iconScale=1+Math.min(.45,Math.max(0,Math.abs(rawOffset)-52)/130); + const iconScale=Math.min(1,Math.max(.01,progress*1.12)); + const badgeStretch=Math.min(Math.max(0,reveal-34),overshoot*1.15); el.style.setProperty('--session-swipe-offset',offset+'px'); el.style.setProperty('--session-swipe-reveal',reveal+'px'); el.style.setProperty('--session-swipe-icon-scale',iconScale); + el.style.setProperty('--session-swipe-badge-stretch',badgeStretch+'px'); el.style.setProperty('--session-swipe-progress',Math.pow(progress,1.5)); el.classList.toggle('swiping-right',offset>0); el.classList.toggle('swiping-left',offset<0); @@ -3872,6 +3877,7 @@ function renderSessionListFromCache(){ el.style.removeProperty('--session-swipe-offset'); el.style.removeProperty('--session-swipe-reveal'); el.style.removeProperty('--session-swipe-icon-scale'); + el.style.removeProperty('--session-swipe-badge-stretch'); el.style.removeProperty('--session-swipe-progress'); el.style.removeProperty('height'); el.style.removeProperty('min-height'); diff --git a/static/style.css b/static/style.css index ab23024912..51a95324c2 100644 --- a/static/style.css +++ b/static/style.css @@ -735,7 +735,7 @@ justify-content:center; gap:3px; width:var(--session-swipe-reveal,0px); - padding:0 8px; + padding:0; box-sizing:border-box; opacity:0; overflow:hidden; @@ -746,16 +746,18 @@ .session-swipe-affordance-right{ left:0; color:#fff; - background:var(--warning); - border-radius:8px 6px 6px 8px; + background:transparent; + border-radius:0; transform-origin:left center; + --session-swipe-action-color:var(--warning); } .session-swipe-affordance-left{ right:0; color:#fff; - background:var(--error); - border-radius:6px 8px 8px 6px; + background:transparent; + border-radius:0; 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{ @@ -763,13 +765,15 @@ } .session-text,.session-state-indicator,.session-actions{z-index:1;} .session-swipe-badge{ - width:28px; - height:28px; - border-radius:0; + width:calc(34px + var(--session-swipe-badge-stretch,0px)); + height:34px; + border-radius:999px; + background:var(--session-swipe-action-color); display:flex; align-items:center; justify-content:center; color:#fff; + flex:0 0 auto; transform:scale(var(--session-swipe-icon-scale,1)); transform-origin:center; } @@ -804,7 +808,7 @@ .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{background:var(--success);} + .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));} diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 6f896ef32e..8d6fada33b 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -121,12 +121,16 @@ def test_session_swipe_paint_uses_transform_only_exit(): 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 iconScale=Math.min(1,Math.max(.01,progress*1.12));" in paint + assert "const badgeStretch=Math.min(Math.max(0,reveal-34),overshoot*1.15);" 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-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))" in STYLE_CSS @@ -140,6 +144,29 @@ def test_session_swipe_paint_uses_transform_only_exit(): 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 "--session-swipe-action-color:var(--warning)" in right + assert "--session-swipe-action-color:var(--error)" in left + assert "width:calc(34px + var(--session-swipe-badge-stretch,0px))" in badge + assert "height:34px" in badge + assert "border-radius:999px" in badge + assert "background:var(--session-swipe-action-color)" 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 + label = STYLE_CSS[STYLE_CSS.find(".session-swipe-label{"):STYLE_CSS.find(".session-item.dragging")] + assert "session-swipe-label" in SESSIONS_JS + assert "max-width:58px" in label + assert "font-size:10px" in label + + def test_session_removal_reflows_surviving_rows_smoothly(): assert "let _pendingSessionReflowPositions = null;" in SESSIONS_JS assert "const _optimisticallyRemovedSessionIds = new Set();" in SESSIONS_JS @@ -159,6 +186,16 @@ def test_session_removal_reflows_surviving_rows_smoothly(): 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 From fa57868431b8dc6231ca7e962653356046c70401 Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Sun, 24 May 2026 20:05:20 -0400 Subject: [PATCH 022/349] feat(chat): add WebUI prefill script hook --- ARCHITECTURE.md | 3 + CHANGELOG.md | 4 ++ README.md | 32 +++++++++ api/streaming.py | 106 ++++++++++++++++++++++++---- tests/test_webui_prefill_context.py | 66 ++++++++++++++++- 5 files changed, 196 insertions(+), 15 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6b865018d7..c9834c3664 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -122,6 +122,9 @@ Environment variables controlling behavior: HERMES_WEBUI_DEFAULT_MODEL Optional model override; unset means provider default HERMES_WEBUI_PASSWORD Optional: enable password auth (off by default) HERMES_WEBUI_SKIP_ONBOARDING Optional: bypass the first-run onboarding wizard + HERMES_PREFILL_MESSAGES_FILE Optional JSON message list for browser-turn prefill context + HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT Optional command that prints JSON messages or text prefill context + HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT Optional script timeout in seconds (default 5, max 30) HERMES_HOME Base directory for Hermes state (~/.hermes by default) Test isolation environment variables (set by conftest.py): diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f805e72b..bbc2614a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Added + +- WebUI can now opt into a `webui_prefill_messages_script` / `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` hook for dynamic browser-turn prefill context from local notes or recall systems. The script output is normalized to ephemeral prefill messages and browser status still hides message bodies while redacting script errors. + ## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish) ### Added diff --git a/README.md b/README.md index ccaca24114..489f7b71ae 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,38 @@ For self-hosted VM or homelab installs, `ctl.sh` wraps the common daemon lifecyc `ctl.sh start` runs the bootstrap in foreground/no-browser mode behind the daemon wrapper, writes logs to `~/.hermes/webui.log`, and respects `.env` plus inline overrides such as `HERMES_WEBUI_HOST=0.0.0.0 ./ctl.sh start`. +### Optional session recall prefill + +WebUI can attach ephemeral prefill messages to new browser-originated +agent turns. This is useful when a deployment already has a local recall script +for Joplin, Obsidian, Notion, llm-wiki, or another third-party notes source and +wants the browser chat to receive the same high-level context as other Hermes +surfaces. + +Static JSON remains supported through `prefill_messages_file` or +`HERMES_PREFILL_MESSAGES_FILE`. For dynamic recall, opt in explicitly with a +WebUI-specific script hook: + +```yaml +webui_prefill_messages_script: + - python3 + - /path/to/notes_recall.py +webui_prefill_messages_script_timeout: 5 +``` + +or: + +```bash +HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT="python3 /path/to/notes_recall.py" \ +HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT=5 \ +./ctl.sh restart +``` + +The script may print either an OpenAI-style JSON message list, a JSON object with +a `messages` list, or plain text; plain text is wrapped as one `system` prefill +message. The browser only receives a compact status event (`source`, `label`, +message count, and redacted errors), never the prefill message bodies. + The bootstrap will: 1. Detect Hermes Agent and, if missing, attempt the official installer (`curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`). diff --git a/api/streaming.py b/api/streaming.py index d319d9d7d7..6a46311ed1 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -10,7 +10,9 @@ import os import queue import re +import shlex import sys +import subprocess import threading import time import traceback @@ -285,29 +287,105 @@ def _resolve_prefill_path(raw: str) -> Path: return path +def _prefill_not_configured() -> dict: + return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0} + + +def _load_prefill_messages_file(file_raw: str, *, source: str = "file", status: str = "loaded") -> dict: + path = _resolve_prefill_path(file_raw) + label = path.name or "prefill file" + if not path.exists(): + return {"status": "error", "source": source, "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"} + try: + messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8"))) + return {"status": status, "source": source, "label": label, "messages": messages, "message_count": len(messages)} + except Exception as exc: + return {"status": "error", "source": source, "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} + + +def _prefill_script_timeout(config_data: dict) -> float: + raw = os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT_TIMEOUT", "") or str(config_data.get("webui_prefill_messages_script_timeout") or "") + try: + return max(0.1, min(float(raw or 5), 30.0)) + except Exception: + return 5.0 + + +def _prefill_script_command(raw) -> list[str]: + if isinstance(raw, (list, tuple)): + return [str(part) for part in raw if str(part)] + parts = shlex.split(str(raw or "")) + if not parts: + return [] + # A single script path mirrors prefill_messages_file path resolution. More + # complex commands keep their argv untouched so admins can pass arguments. + if len(parts) == 1: + parts[0] = str(_resolve_prefill_path(parts[0])) + return parts + + +def _messages_from_prefill_script_output(text: str) -> list[dict]: + stripped = str(text or "").strip() + if not stripped: + return [] + try: + payload = json.loads(stripped) + except Exception: + payload = None + if isinstance(payload, dict): + payload = payload.get("messages") + messages = _valid_prefill_messages(payload) + if messages: + return messages + return [{"role": "system", "content": stripped}] + + +def _load_prefill_messages_script(config_data: dict) -> dict: + script_raw = os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT", "") or config_data.get("webui_prefill_messages_script") + if not script_raw: + return _prefill_not_configured() + command = _prefill_script_command(script_raw) + label = Path(command[0]).name if command else "prefill script" + if not command: + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script is empty"} + try: + proc = subprocess.run( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=_prefill_script_timeout(config_data), + check=False, + ) + except subprocess.TimeoutExpired: + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": "prefill script timed out"} + except Exception as exc: + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} + if proc.returncode != 0: + err = _redact_prefill_status_text(proc.stderr or proc.stdout or f"prefill script exited {proc.returncode}") + return {"status": "error", "source": "script", "label": label, "messages": [], "message_count": 0, "error": err} + messages = _messages_from_prefill_script_output(proc.stdout) + return {"status": "loaded", "source": "script", "label": label, "messages": messages, "message_count": len(messages)} + + def _load_webui_prefill_context( config_data: Optional[dict] = None, ) -> dict: """Load configured WebUI session prefill messages. - Supports the same bounded JSON-file shape used by Hermes Agent. WebUI does - not execute a configured prefill script here; session recall that requires - code execution should go through the normal MCP/tool path instead of an - always-on per-turn subprocess before SSE starts. + Supports the same bounded JSON-file shape used by Hermes Agent. WebUI also + supports its own explicitly opt-in script hook so admins can bridge Joplin, + Obsidian, Notion, llm-wiki, or another local notes source into ephemeral + turn context without baking any one note provider into the WebUI. """ cfg = config_data if isinstance(config_data, dict) else get_config() + script_context = _load_prefill_messages_script(cfg) + if script_context.get("status") != "not_configured": + return script_context file_raw = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or str(cfg.get("prefill_messages_file") or "") if file_raw: - path = _resolve_prefill_path(file_raw) - label = path.name or "prefill file" - if not path.exists(): - return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": "prefill file not found"} - try: - messages = _valid_prefill_messages(json.loads(path.read_text(encoding="utf-8"))) - return {"status": "loaded", "source": "file", "label": label, "messages": messages, "message_count": len(messages)} - except Exception as exc: - return {"status": "error", "source": "file", "label": label, "messages": [], "message_count": 0, "error": _redact_prefill_status_text(str(exc))} - return {"status": "not_configured", "source": "none", "label": "", "messages": [], "message_count": 0} + return _load_prefill_messages_file(file_raw) + return _prefill_not_configured() def _public_prefill_context_status(prefill_context: dict) -> dict: diff --git a/tests/test_webui_prefill_context.py b/tests/test_webui_prefill_context.py index 06a18e0ceb..3584aba281 100644 --- a/tests/test_webui_prefill_context.py +++ b/tests/test_webui_prefill_context.py @@ -2,6 +2,8 @@ from __future__ import annotations import json +import sys +from pathlib import Path def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path): @@ -32,7 +34,7 @@ def test_prefill_json_file_keeps_valid_roles_and_drops_invalid_items(tmp_path): ] -def test_prefill_script_config_is_ignored_in_webui(tmp_path): +def test_prefill_script_config_is_not_used_without_webui_opt_in(tmp_path): from api.streaming import _load_webui_prefill_context script = tmp_path / "recall.py" @@ -49,6 +51,68 @@ def test_prefill_script_config_is_ignored_in_webui(tmp_path): } +def test_webui_prefill_script_loads_json_messages(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "recall.py" + script.write_text( + "import json\n" + "print(json.dumps([{'role': 'system', 'content': 'Joplin recall'}, {'role': 'tool', 'content': 'drop me'}]))\n", + encoding="utf-8", + ) + + result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]}) + + assert result["status"] == "loaded" + assert result["source"] == "script" + assert result["label"] == Path(sys.executable).name + assert result["messages"] == [{"role": "system", "content": "Joplin recall"}] + + +def test_webui_prefill_script_wraps_plain_text_for_any_notes_source(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "obsidian_recall.py" + script.write_text("print('Obsidian project note context')\n", encoding="utf-8") + + result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]}) + + assert result["status"] == "loaded" + assert result["source"] == "script" + assert result["messages"] == [{"role": "system", "content": "Obsidian project note context"}] + + +def test_webui_prefill_script_errors_are_redacted(tmp_path): + from api.streaming import _load_webui_prefill_context + + script = tmp_path / "bad_recall.py" + script.write_text("import sys; print('token=redaction-test-placeholder', file=sys.stderr); raise SystemExit(2)\n", encoding="utf-8") + + result = _load_webui_prefill_context({"webui_prefill_messages_script": [sys.executable, str(script)]}) + + assert result["status"] == "error" + assert result["source"] == "script" + assert "redaction-test-placeholder" not in result["error"] + assert "[REDACTED]" in result["error"] + + +def test_webui_prefill_script_takes_precedence_over_static_file(tmp_path): + from api.streaming import _load_webui_prefill_context + + prefill = tmp_path / "prefill.json" + prefill.write_text(json.dumps([{"role": "system", "content": "static"}]), encoding="utf-8") + script = tmp_path / "recall.py" + script.write_text("print('dynamic')\n", encoding="utf-8") + + result = _load_webui_prefill_context({ + "prefill_messages_file": str(prefill), + "webui_prefill_messages_script": [sys.executable, str(script)], + }) + + assert result["source"] == "script" + assert result["messages"] == [{"role": "system", "content": "dynamic"}] + + def test_public_prefill_status_strips_message_bodies(): from api.streaming import _public_prefill_context_status From efe3d7c296f6e769a43bd1781a5ecdfba6d4b64b Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 22 May 2026 10:41:10 +0200 Subject: [PATCH 023/349] fix(chat): avoid false restart wording for interrupted responses (cherry picked from commit ef8fd879682aeb729a7b7afa1e7c46478ca5ebb6) --- CHANGELOG.md | 8 +++--- api/models.py | 8 +++--- api/run_journal.py | 2 +- api/streaming.py | 2 +- docs/troubleshooting.md | 4 +-- tests/test_run_journal.py | 4 +++ .../test_session_lost_response_regression.py | 25 +++++++++++++++++-- tests/test_session_sidecar_repair.py | 6 +++-- 8 files changed, 44 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f805e72b..64fa994fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### Fixed + +- Clarify `Response interrupted` recovery markers so they report that the live response stream stopped instead of asserting that the WebUI process restarted. The same stale-recovery path also covers browser/SSE disconnects and lost worker bookkeeping, so the marker now matches systemd evidence instead of implying a restart that did not happen. + ## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish) ### Added @@ -33,8 +37,6 @@ - **Opus Advisor verdict: SHIP-AS-IS.** Zero MUST-FIX. Three SHOULD-FIX items filed as follow-up issues (incomplete locale coverage for notes-drawer i18n keys, `_joplin_api_get` URL-token defense-in-depth, prefill `setattr` cache-reuse safety net). - **#2527 i18n coverage**: 10 of the 11 non-en locales currently ship the English string `'Third-party notes'` for the drawer header. Since the drawer is default-off, user impact is zero today; follow-up issue tracks proper translations before any default-on transition. - - ## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab) ### Fixed @@ -143,6 +145,7 @@ - UX evidence for #2812 captured at 1280/1440/1920/mobile (iPhone 14 emulation); Telegram-approved. - File a follow-up issue for pdeathsig-on-supervisor-thread hardening (#2854 deferred Option B) and French-locale `open_in_vscode` parity gap (predates this batch, Opus advisor flagged). + ## [v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline) ### Added @@ -326,7 +329,6 @@ - **PR #2738** by @weidzhou — `_write_session_index()` full-rebuild path now deduplicates entries by `session_id`. When old-format `session_*.json` files coexist with WebUI-format `xxx.json` files sharing the same `session_id`, the index produced duplicate Vue `:key` entries and crashed the frontend with a blank page. The lazy rebuild now uses `dict[session_id → compact_entry]` keyed on session_id, with the higher `message_count` entry winning on conflict. - **PR #2730** by @ashbuildslife — Sanitize git fetch diagnostics before returning update-check errors to the browser. New `_sanitize_git_diagnostic()` in `api/updates.py` strips credentialed URL userinfo (`user:token@host`), GitHub token shapes (`ghp_*`, `gho_*`, `github_pat_*`), and secret-looking query parameters (`?access_token=`, `?token=`, `?password=`, `?auth=`, `?key=`), then caps the message at 300 characters. Empirically verified that plain `https://github.com/owner/repo.git` URLs and SSH-style `git@host:owner/repo` remotes pass through untouched — only credentialed shapes are redacted. Update-check failure context (e.g. `Authentication failed`, network errors) is preserved. - **PR #2742** by @Isla-Liu — Per-turn SQLite connection leak in handoff-summary path (#2233). Two functions on the `/api/session/handoff-summary` hot path were opening `sqlite3.connect(...)` inside a bare `with` statement, which commits the transaction at scope exit but does NOT close the connection. Per-turn invocations accumulated `state.db`/`state.db-wal` file descriptors and CPython heap pages on long-lived worker threads, surfacing as multi-GB VmRSS / 6× duplicated state.db fds on long-running installs. Wrapped both call sites with `contextlib.closing(...)` (already imported and used at 7 other sites in the same files) so the connection is closed deterministically: `api/models.py::count_conversation_rounds` and `api/routes.py::_persist_handoff_summary_to_state_db`. Regression test loops both functions 20× against a tmp `state.db` and asserts `/proc//fd` count does not grow more than 2. Live soak: fd growth = 0, VmRSS growth = 0 KB across 20 POSTs. - ## [v0.51.107] — 2026-05-21 — Release CE (stage-400 — 8-PR batch — pinned-sessions-limit getter rename + uploaded-file user-turn dedupe + active-run repair guard + incremental KaTeX streaming + profile default model on fresh boot + French locale completion + update-check error surfacing + release-update apply path) ### Fixed diff --git a/api/models.py b/api/models.py index 0a734bc5fe..1317427bae 100644 --- a/api/models.py +++ b/api/models.py @@ -764,18 +764,18 @@ def _get_profile_home(profile) -> Path: _INTERRUPTED_RECOVERED_WORDING = ( '**Response interrupted.**\n\n' - 'The WebUI process restarted before this turn finished. ' + 'The live response stream stopped before this turn finished. ' 'The partial output above was recovered from the run journal, ' 'but the interrupted agent process could not continue.' ) _INTERRUPTED_NO_OUTPUT_WORDING = ( '**Response interrupted.**\n\n' - 'The WebUI process restarted before this turn finished. ' + 'The live response stream stopped before this turn finished. ' 'The user message above was preserved, but no agent output was recovered.' ) _INTERRUPTED_PENDING_RETRY_WORDING = ( '**Response interrupted.**\n\n' - 'The WebUI process restarted before this turn finished. ' + 'The live response stream stopped before this turn finished. ' 'Recovering the partial output from the run journal — ' 'reload this session to retry.' ) @@ -783,7 +783,7 @@ def _get_profile_home(profile) -> Path: # or the marker has been pending longer than _JOURNAL_RETRY_GIVEUP_SECONDS). _INTERRUPTED_NEUTRAL_WORDING = ( '**Response interrupted.**\n\n' - 'The WebUI process restarted before this turn finished. ' + 'The live response stream stopped before this turn finished. ' 'Partial output may have been lost.' ) diff --git a/api/run_journal.py b/api/run_journal.py index 0a0f42ff10..92b42e50cf 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -262,7 +262,7 @@ def stale_interrupted_event(session_id: str, run_id: str, *, after_seq: int | No return None payload = { "type": "interrupted", - "message": "WebUI restarted or lost the live worker before this run finished.", + "message": "The live worker stopped before this run finished.", "hint": "The transcript was restored to the last journaled event. Start a new turn if you still need the task to continue.", "session_id": session_id, "stream_id": run_id, diff --git a/api/streaming.py b/api/streaming.py index d319d9d7d7..db6488030f 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2613,7 +2613,7 @@ def _stream_writeback_can_supersede_recovery_marker(session, msg_text): if last.get('type') != 'interrupted': return False content = str(last.get('content') or '') - if 'Response interrupted' not in content or 'WebUI process restarted' not in content: + if 'Response interrupted' not in content or 'before this turn finished' not in content: return False expected = ' '.join(str(msg_text or '').split()) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 78f5f4ece8..7464284ea8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -87,9 +87,9 @@ If after running steps 1-4 the import still fails *and* `pip install -e .` succe ## "Response interrupted." marker keeps saying "no agent output was recovered" -**Symptom.** After the WebUI process restarts mid-turn (manual restart, OOM, crash, …), the affected chat shows an `**Response interrupted.**` marker with the wording *"The user message above was preserved, but no agent output was recovered."*, even though the run-journal for that turn is present on disk and contains the partial tokens the agent had already streamed. +**Symptom.** After a live response stream stops before a turn completes (manual restart, OOM, crash, browser/SSE disconnect, lost worker bookkeeping, …), the affected chat shows an `**Response interrupted.**` marker with the wording *"The user message above was preserved, but no agent output was recovered."*, even though the run-journal for that turn is present on disk and contains the partial tokens the agent had already streamed. -**Why.** Sidecar repair re-checks the run-journal at restart and uses the result as a one-shot signal. On WSL2 (9p / DrvFs) and on some network-backed setups, the run-journal `.jsonl` is written by the dead worker but the WebUI process reads it through a page-cache state that has not yet seen those writes — recovery returns "empty" and the marker is baked permanently. The fix introduces a *lazy* retry path: when sidecar repair cannot read visible output but knows the stream id, it stores a `_pending_journal_recovery` flag on the marker and re-attempts recovery from `get_session()` until the journal becomes readable (or the retry budget is exhausted). +**Why.** Sidecar repair re-checks the run-journal after it detects a stale stream and uses the result as a one-shot signal. On WSL2 (9p / DrvFs) and on some network-backed setups, the run-journal `.jsonl` is written by the stopped worker but the WebUI process reads it through a page-cache state that has not yet seen those writes — recovery returns "empty" and the marker is baked permanently. The fix introduces a *lazy* retry path: when sidecar repair cannot read visible output but knows the stream id, it stores a `_pending_journal_recovery` flag on the marker and re-attempts recovery from `get_session()` until the journal becomes readable (or the retry budget is exhausted). **Diagnostic.** diff --git a/tests/test_run_journal.py b/tests/test_run_journal.py index b7fb0b2d69..3fd96ac9e6 100644 --- a/tests/test_run_journal.py +++ b/tests/test_run_journal.py @@ -112,12 +112,16 @@ def test_stale_interrupted_event_reports_non_terminal_journal(tmp_path, monkeypa monkeypatch.setattr("api.run_journal._default_session_dir", lambda: tmp_path) event = stale_interrupted_event("session_1", "run_1") + assert event is not None assert event["event"] == "apperror" assert event["seq"] == 2 assert event["terminal_state"] == "stale-from-restart" assert event["payload"]["type"] == "interrupted" assert "last journaled event" in event["payload"]["hint"] + assert "process restarted" not in event["payload"]["message"] + assert "lost the live worker" not in event["payload"]["message"] + assert "live worker stopped" in event["payload"]["message"] def test_stale_interrupted_event_skips_terminal_journal(tmp_path, monkeypatch): diff --git a/tests/test_session_lost_response_regression.py b/tests/test_session_lost_response_regression.py index 3349652d39..04bddea6df 100644 --- a/tests/test_session_lost_response_regression.py +++ b/tests/test_session_lost_response_regression.py @@ -2,8 +2,8 @@ The scenario this test pins down: -1. A WebUI process restarts mid-stream. On the first sidecar repair attempt - the run-journal for the dead stream is NOT visible yet (page-cache loss, +1. A WebUI live response stream stops mid-turn. On the first sidecar repair + attempt the run-journal for the dead stream is NOT visible yet (page-cache loss, un-fsynced writes, slow network FS, etc.) so `_append_journaled_partial_output` returns False. 2. Pre-fix the repair path baked a permanent "no agent output was recovered" @@ -251,6 +251,27 @@ def test_state_db_middle_segment_replay_does_not_append_after_sidecar_tail(): assert merged[-1]["content"] == "opened browser preview" +def test_interrupted_recovery_markers_do_not_claim_restart_as_fact(): + """A stale live worker is not always a WebUI process restart. + + Broken SSE connections, browser disconnects, lost worker bookkeeping, and + real restarts all enter the same recovery marker path. User-visible wording + must describe the generic interruption instead of asserting a process + restart that systemd evidence may later disprove. + """ + marker_texts = [ + models._INTERRUPTED_RECOVERED_WORDING, + models._INTERRUPTED_NO_OUTPUT_WORDING, + models._INTERRUPTED_PENDING_RETRY_WORDING, + models._INTERRUPTED_NEUTRAL_WORDING, + ] + + for text in marker_texts: + assert "Response interrupted" in text + assert "process restarted" not in text + assert "before this turn finished" in text + + def test_lost_response_recovered_on_second_read(hermes_home): sid = "9f14583f0e4e4444aaaa111122223333" stream_id = "7c8b4108d52b4aba9af362d3a54f47ac" diff --git a/tests/test_session_sidecar_repair.py b/tests/test_session_sidecar_repair.py index cae192f5f5..44a6edb4de 100644 --- a/tests/test_session_sidecar_repair.py +++ b/tests/test_session_sidecar_repair.py @@ -319,7 +319,8 @@ def test_error_marker_no_preserved_as_draft(self, hermes_home, monkeypatch): f"Error marker should not say 'preserved as a draft', got: {content}" ) assert "Response interrupted" in content - assert "WebUI process restarted" in content + assert "live response stream stopped" in content + assert "WebUI process restarted" not in content # The marker now arms the lazy-retry hook when a stream id is known # ("Recovering the partial output… reload to retry."). The legacy # "user message above was preserved" wording is reserved for the @@ -624,7 +625,8 @@ def test_pending_cleared_when_messages_nonempty(self, hermes_home, monkeypatch): error_msgs = [m for m in s.messages if m.get("_error")] assert len(error_msgs) == 1 assert "Response interrupted" in error_msgs[0]["content"] - assert "WebUI process restarted" in error_msgs[0]["content"] + assert "live response stream stopped" in error_msgs[0]["content"] + assert "WebUI process restarted" not in error_msgs[0]["content"] assert error_msgs[0].get("type") == "interrupted" # Pending fields fully cleared From 2f1ca959f182c50c92dfb743ca323f225f0e1976 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 22 May 2026 11:08:08 +0200 Subject: [PATCH 024/349] fix(chat): classify interrupted response causes (cherry picked from commit 5c1e802cd6ee8565da74c7ffe57e6407fe21bf02) --- CHANGELOG.md | 2 +- api/models.py | 128 ++++++++++++++++-- api/routes.py | 2 +- api/run_journal.py | 2 +- docs/troubleshooting.md | 11 +- server.py | 10 ++ static/messages.js | 4 +- tests/test_run_journal.py | 2 +- tests/test_run_journal_routes.py | 4 +- .../test_session_lost_response_regression.py | 58 ++++++++ 10 files changed, 200 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64fa994fe4..c59ed1d9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed -- Clarify `Response interrupted` recovery markers so they report that the live response stream stopped instead of asserting that the WebUI process restarted. The same stale-recovery path also covers browser/SSE disconnects and lost worker bookkeeping, so the marker now matches systemd evidence instead of implying a restart that did not happen. +- Clarify `Response interrupted` recovery markers so they report that the live response stream stopped instead of asserting that the WebUI process restarted. The recovery path now records distinct interruption causes for real process restarts, stream/run split-brain, and lost worker bookkeeping; browser-side SSE transport failures show a separate `Connection interrupted` message, and client-side `BrokenPipeError` disconnects no longer get logged as server 500s. ## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish) diff --git a/api/models.py b/api/models.py index 1317427bae..fd6161ec57 100644 --- a/api/models.py +++ b/api/models.py @@ -787,11 +787,87 @@ def _get_profile_home(profile) -> Path: 'Partial output may have been lost.' ) +_INTERRUPTION_CAUSE_DETAILS = { + 'process_restart': ( + 'Evidence: the WebUI process started after this turn began, so this ' + 'looks like a real process crash or restart.' + ), + 'stream_run_split_brain': ( + 'Evidence: the browser response stream was gone but the worker registry ' + 'still listed the run. This is a stream/run bookkeeping split-brain.' + ), + 'lost_worker_bookkeeping': ( + 'Evidence: the stream was gone and worker bookkeeping no longer had an ' + 'active run for it. This usually means the worker state was lost or ' + 'cleaned up without a terminal event.' + ), + 'unknown': ( + 'Evidence: the stream stopped, but the WebUI could not classify the ' + 'interruption more precisely.' + ), +} + + +def _classify_interruption_cause( + *, stream_id: str | None = None, pending_started_at=None, +) -> str: + """Classify the stale live-response state without overstating certainty.""" + try: + started = float(pending_started_at) if pending_started_at else None + except (TypeError, ValueError): + started = None + + if started is not None: + try: + if float(getattr(_cfg, 'SERVER_START_TIME', 0.0) or 0.0) > started: + return 'process_restart' + except (TypeError, ValueError): + pass + + if stream_id: + try: + with _cfg.ACTIVE_RUNS_LOCK: + if str(stream_id) in _cfg.ACTIVE_RUNS: + return 'stream_run_split_brain' + except Exception: + pass + return 'lost_worker_bookkeeping' + + return 'unknown' + + +def _interrupted_content_for( + *, recovered_output: bool, pending_retry: bool, interruption_cause: str, +) -> str: + if recovered_output: + outcome = ( + 'The partial output above was recovered from the run journal, ' + 'but the interrupted agent process could not continue.' + ) + elif pending_retry: + outcome = ( + 'Recovering the partial output from the run journal — ' + 'reload this session to retry.' + ) + else: + outcome = 'The user message above was preserved, but no agent output was recovered.' + cause_detail = _INTERRUPTION_CAUSE_DETAILS.get( + interruption_cause, + _INTERRUPTION_CAUSE_DETAILS['unknown'], + ) + return ( + '**Response interrupted.**\n\n' + 'The live response stream stopped before this turn finished. ' + f'{cause_detail} {outcome}' + ) + def _interrupted_recovery_marker( *, recovered_output: bool = False, pending_retry: bool = False, + stream_id: str | None = None, + pending_started_at=None, ) -> dict: """Build the standard interrupted-turn marker. @@ -809,18 +885,22 @@ def _interrupted_recovery_marker( set so the caller cannot accidentally re-arm retry on a successful repair. """ - if recovered_output: - content = _INTERRUPTED_RECOVERED_WORDING - elif pending_retry: - content = _INTERRUPTED_PENDING_RETRY_WORDING - else: - content = _INTERRUPTED_NO_OUTPUT_WORDING + interruption_cause = _classify_interruption_cause( + stream_id=stream_id, + pending_started_at=pending_started_at, + ) + content = _interrupted_content_for( + recovered_output=recovered_output, + pending_retry=pending_retry, + interruption_cause=interruption_cause, + ) marker = { 'role': 'assistant', 'content': content, 'timestamp': int(time.time()), '_error': True, 'type': 'interrupted', + 'interruption_cause': interruption_cause, } if pending_retry and not recovered_output: marker['_pending_journal_recovery'] = True @@ -1218,15 +1298,26 @@ def _journal_retry_lock_for_sid(sid: str) -> threading.Lock: def _build_recovery_marker_with_retry_hook( - *, recovered_output: bool, stream_id: str | None, + *, recovered_output: bool, stream_id: str | None, pending_started_at=None, ) -> dict: """Build an interrupted-turn marker, arming the lazy-retry hook when visible output was not recovered yet but a stream id is available.""" if recovered_output: - return _interrupted_recovery_marker(recovered_output=True) + return _interrupted_recovery_marker( + recovered_output=True, + stream_id=stream_id, + pending_started_at=pending_started_at, + ) if not stream_id: - return _interrupted_recovery_marker(recovered_output=False) - marker = _interrupted_recovery_marker(pending_retry=True) + return _interrupted_recovery_marker( + recovered_output=False, + pending_started_at=pending_started_at, + ) + marker = _interrupted_recovery_marker( + pending_retry=True, + stream_id=stream_id, + pending_started_at=pending_started_at, + ) marker['_journal_retry_stream_id'] = str(stream_id) marker['_journal_retry_attempts'] = 0 marker['_journal_retry_first_seen_ts'] = int(time.time()) @@ -1511,13 +1602,16 @@ def _apply_core_sync_or_error_marker( stream_id_for_recheck or session.active_stream_id, ) _stream_id = stream_id_for_recheck or session.active_stream_id + _pending_started_at = session.pending_started_at session.active_stream_id = None session.pending_user_message = None session.pending_attachments = [] session.pending_started_at = None session.messages.append( _build_recovery_marker_with_retry_hook( - recovered_output=recovered_output, stream_id=_stream_id, + recovered_output=recovered_output, + stream_id=_stream_id, + pending_started_at=_pending_started_at, ) ) session.save(touch_updated_at=touch_updated_at) @@ -1562,13 +1656,18 @@ def _apply_core_sync_or_error_marker( _stream_id, dedupe_existing=True, ) + _pending_started_at = session.pending_started_at session.active_stream_id = None session.pending_user_message = None session.pending_attachments = [] session.pending_started_at = None if recovered_output: session.messages.append( - _interrupted_recovery_marker(recovered_output=True) + _interrupted_recovery_marker( + recovered_output=True, + stream_id=_stream_id, + pending_started_at=_pending_started_at, + ) ) # NOTE: when the core transcript was synced in but the run journal # is not yet visible, intentionally do NOT append a lazy-retry @@ -1604,13 +1703,16 @@ def _apply_core_sync_or_error_marker( stream_id_for_recheck or session.active_stream_id, ) _stream_id = stream_id_for_recheck or session.active_stream_id + _pending_started_at = session.pending_started_at session.active_stream_id = None session.pending_user_message = None session.pending_attachments = [] session.pending_started_at = None session.messages.append( _build_recovery_marker_with_retry_hook( - recovered_output=recovered_output, stream_id=_stream_id, + recovered_output=recovered_output, + stream_id=_stream_id, + pending_started_at=_pending_started_at, ) ) session.save(touch_updated_at=touch_updated_at) diff --git a/api/routes.py b/api/routes.py index b2aef7f75f..8edc587293 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1126,7 +1126,7 @@ def _run_journal_status_payload(summary: dict, *, active: bool = False) -> dict: terminal = bool(summary.get("terminal")) terminal_state = summary.get("terminal_state") if not active and not terminal: - terminal_state = "stale-from-restart" + terminal_state = "lost-worker-bookkeeping" return { "session_id": summary.get("session_id"), "run_id": summary.get("run_id"), diff --git a/api/run_journal.py b/api/run_journal.py index 92b42e50cf..53062fbf88 100644 --- a/api/run_journal.py +++ b/api/run_journal.py @@ -278,7 +278,7 @@ def stale_interrupted_event(session_id: str, run_id: str, *, after_seq: int | No "type": "apperror", "created_at": time.time(), "terminal": True, - "terminal_state": "stale-from-restart", + "terminal_state": "lost-worker-bookkeeping", "payload": payload, "synthetic": True, } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 7464284ea8..2230b52e5a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -87,9 +87,16 @@ If after running steps 1-4 the import still fails *and* `pip install -e .` succe ## "Response interrupted." marker keeps saying "no agent output was recovered" -**Symptom.** After a live response stream stops before a turn completes (manual restart, OOM, crash, browser/SSE disconnect, lost worker bookkeeping, …), the affected chat shows an `**Response interrupted.**` marker with the wording *"The user message above was preserved, but no agent output was recovered."*, even though the run-journal for that turn is present on disk and contains the partial tokens the agent had already streamed. +**Symptom.** After a live response stream stops before a turn completes (manual restart, OOM, crash, browser/SSE disconnect, lost worker bookkeeping, …), the affected chat shows an `**Response interrupted.**` marker. If the run-journal for that turn is already visible on disk, the marker says the partial output was recovered; if not, it preserves the user turn and says no agent output was recovered yet. -**Why.** Sidecar repair re-checks the run-journal after it detects a stale stream and uses the result as a one-shot signal. On WSL2 (9p / DrvFs) and on some network-backed setups, the run-journal `.jsonl` is written by the stopped worker but the WebUI process reads it through a page-cache state that has not yet seen those writes — recovery returns "empty" and the marker is baked permanently. The fix introduces a *lazy* retry path: when sidecar repair cannot read visible output but knows the stream id, it stores a `_pending_journal_recovery` flag on the marker and re-attempts recovery from `get_session()` until the journal becomes readable (or the retry budget is exhausted). +**Why.** Sidecar repair re-checks the run-journal after it detects a stale stream and uses the result as a one-shot signal. On WSL2 (9p / DrvFs) and on some network-backed setups, the run-journal `.jsonl` is written by the stopped worker but the WebUI process reads it through a page-cache state that has not yet seen those writes — recovery returns "empty" and the marker would otherwise be baked permanently. The fix introduces a *lazy* retry path: when sidecar repair cannot read visible output but knows the stream id, it stores a `_pending_journal_recovery` flag on the marker and re-attempts recovery from `get_session()` until the journal becomes readable (or the retry budget is exhausted). + +**Interruption classes.** The WebUI now keeps the user-facing cases separate instead of implying every stale stream was a restart: + +- **Browser/SSE connection interrupted** — the live browser `EventSource` transport dropped. The UI reports `Connection interrupted` and tries status/replay/session restore before showing the final browser-side notice. +- **Lost worker bookkeeping** — the stream id is gone and the worker registry no longer has an active run. Recovery markers carry `interruption_cause: "lost_worker_bookkeeping"` and `/api/chat/stream/status` reports `terminal_state: "lost-worker-bookkeeping"` for non-terminal journals that are no longer active. +- **Stream/run split-brain** — the stream is gone but `ACTIVE_RUNS` still lists the worker. Recovery markers carry `interruption_cause: "stream_run_split_brain"` so the transcript says this is a bookkeeping split-brain rather than a restart. +- **Process crash/restart** — `SERVER_START_TIME` is newer than `pending_started_at`, meaning the WebUI process started after the turn began. Recovery markers carry `interruption_cause: "process_restart"` and explicitly say the process-start evidence points to a crash or restart. **Diagnostic.** diff --git a/server.py b/server.py index e6a2c65a4a..f838d3372b 100644 --- a/server.py +++ b/server.py @@ -258,6 +258,11 @@ def do_GET(self) -> None: result = handle_get(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + # The browser/client closed the socket while we were writing the + # response. This is expected for probes, tab closes, and SSE + # reconnect races; do not convert it into a misleading server 500. + return except Exception as e: print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True) return j(self, {'error': 'Internal server error'}, status=500) @@ -284,6 +289,11 @@ def _handle_write(self, route_func) -> None: result = route_func(self, parsed) if result is False: return j(self, {'error': 'not found'}, status=404) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + # The browser/client closed the socket while we were writing the + # response. This is expected for probes, tab closes, and SSE + # reconnect races; do not convert it into a misleading server 500. + return except Exception as e: print(f'[webui] ERROR {self.command} {self.path}\n' + traceback.format_exc(), flush=True) return j(self, {'error': 'Internal server error'}, status=500) diff --git a/static/messages.js b/static/messages.js index f2cb32998c..7e55b7bb89 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2245,12 +2245,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(S.session&&S.session.session_id===activeSid){ S.activeStreamId=null; clearLiveToolCards();if(!assistantText)removeThinking(); - S.messages.push({role:'assistant',content:'**Error:** Connection lost'});renderMessages({preserveScroll:true}); + S.messages.push({role:'assistant',content:'**Connection interrupted:** The browser lost the live SSE connection before the response finished. If the worker completed, reopening this session should restore the settled transcript.'});renderMessages({preserveScroll:true}); _markSessionViewed(activeSid, S.messages.length); }else{ if(typeof trackBackgroundError==='function'){ const _errTitle=(typeof _allSessions!=='undefined'&&_allSessions.find(s=>s.session_id===activeSid)||{}).title||null; - trackBackgroundError(activeSid,_errTitle,'Connection lost'); + trackBackgroundError(activeSid,_errTitle,'Connection interrupted'); } } _setActivePaneIdleIfOwner(); diff --git a/tests/test_run_journal.py b/tests/test_run_journal.py index 3fd96ac9e6..22a7b24eb4 100644 --- a/tests/test_run_journal.py +++ b/tests/test_run_journal.py @@ -116,7 +116,7 @@ def test_stale_interrupted_event_reports_non_terminal_journal(tmp_path, monkeypa assert event["event"] == "apperror" assert event["seq"] == 2 - assert event["terminal_state"] == "stale-from-restart" + assert event["terminal_state"] == "lost-worker-bookkeeping" assert event["payload"]["type"] == "interrupted" assert "last journaled event" in event["payload"]["hint"] assert "process restarted" not in event["payload"]["message"] diff --git a/tests/test_run_journal_routes.py b/tests/test_run_journal_routes.py index a0a2b34be6..187541cf03 100644 --- a/tests/test_run_journal_routes.py +++ b/tests/test_run_journal_routes.py @@ -40,7 +40,7 @@ def test_replay_emits_event_ids_and_stale_restart_diagnostic(): def test_session_payload_exposes_runtime_journal_for_stale_streams(): assert "original_stream_id = getattr(s, \"active_stream_id\", None)" in ROUTES_SRC assert '"runtime_journal"' in ROUTES_SRC - assert 'terminal_state = "stale-from-restart"' in ROUTES_SRC + assert 'terminal_state = "lost-worker-bookkeeping"' in ROUTES_SRC def test_status_payload_marks_non_terminal_dead_journal_as_stale(): @@ -60,7 +60,7 @@ def test_status_payload_marks_non_terminal_dead_journal_as_stale(): ) assert payload["terminal"] is False - assert payload["terminal_state"] == "stale-from-restart" + assert payload["terminal_state"] == "lost-worker-bookkeeping" assert payload["last_event_id"] == "run_1:3" diff --git a/tests/test_session_lost_response_regression.py b/tests/test_session_lost_response_regression.py index 04bddea6df..3c9707b9e0 100644 --- a/tests/test_session_lost_response_regression.py +++ b/tests/test_session_lost_response_regression.py @@ -53,11 +53,13 @@ def _isolate_stream_state(): config.CANCEL_FLAGS.clear() config.AGENT_INSTANCES.clear() config.STREAM_PARTIAL_TEXT.clear() + config.ACTIVE_RUNS.clear() yield config.STREAMS.clear() config.CANCEL_FLAGS.clear() config.AGENT_INSTANCES.clear() config.STREAM_PARTIAL_TEXT.clear() + config.ACTIVE_RUNS.clear() @pytest.fixture(autouse=True) @@ -272,6 +274,62 @@ def test_interrupted_recovery_markers_do_not_claim_restart_as_fact(): assert "before this turn finished" in text +def test_interrupted_marker_distinguishes_real_process_restart(monkeypatch): + monkeypatch.setattr(config, "SERVER_START_TIME", 2000.0) + marker = models._interrupted_recovery_marker( + recovered_output=False, + stream_id="stream_crash", + pending_started_at=1000.0, + ) + + assert marker["interruption_cause"] == "process_restart" + assert "WebUI process started after this turn began" in marker["content"] + assert "process restarted" not in marker["content"] + + +def test_interrupted_marker_distinguishes_stream_run_split_brain(monkeypatch): + monkeypatch.setattr(config, "SERVER_START_TIME", 1000.0) + config.ACTIVE_RUNS["stream_split"] = {"session_id": "sid", "phase": "running"} + + marker = models._interrupted_recovery_marker( + recovered_output=False, + stream_id="stream_split", + pending_started_at=2000.0, + ) + + assert marker["interruption_cause"] == "stream_run_split_brain" + assert "stream was gone but the worker registry still listed the run" in marker["content"] + + +def test_interrupted_marker_distinguishes_lost_worker_bookkeeping(monkeypatch): + monkeypatch.setattr(config, "SERVER_START_TIME", 1000.0) + + marker = models._interrupted_recovery_marker( + recovered_output=False, + stream_id="stream_lost", + pending_started_at=2000.0, + ) + + assert marker["interruption_cause"] == "lost_worker_bookkeeping" + assert "worker bookkeeping no longer had an active run" in marker["content"] + + +def test_messages_js_names_browser_sse_disconnect_separately(): + repo = models.Path(__file__).parent.parent + js = (repo / "static" / "messages.js").read_text(encoding="utf-8") + + assert "Connection interrupted" in js + assert "browser lost the live SSE connection" in js + assert "Connection lost" not in js + + +def test_server_treats_broken_pipe_as_client_disconnect_not_500(): + server_py = (models.Path(__file__).parent.parent / "server.py").read_text(encoding="utf-8") + + assert "except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):" in server_py + assert "do not convert it into a misleading server 500" in server_py + + def test_lost_response_recovered_on_second_read(hermes_home): sid = "9f14583f0e4e4444aaaa111122223333" stream_id = "7c8b4108d52b4aba9af362d3a54f47ac" From 8a2f11c77099f98da213db90094e29a2504533b7 Mon Sep 17 00:00:00 2001 From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com> Date: Fri, 22 May 2026 11:29:32 +0200 Subject: [PATCH 025/349] fix(chat): log sanitized client sse diagnostics (cherry picked from commit 749ca6e18c5e307fbf7e7fb5fffce97249545017) --- CHANGELOG.md | 2 +- api/routes.py | 135 ++++++++++++++++++++- docs/troubleshooting.md | 2 +- static/messages.js | 7 +- static/sessions.js | 1 + static/workspace.js | 17 +++ tests/test_client_event_diagnostics.py | 158 +++++++++++++++++++++++++ 7 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 tests/test_client_event_diagnostics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c59ed1d9f1..9c964954db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed -- Clarify `Response interrupted` recovery markers so they report that the live response stream stopped instead of asserting that the WebUI process restarted. The recovery path now records distinct interruption causes for real process restarts, stream/run split-brain, and lost worker bookkeeping; browser-side SSE transport failures show a separate `Connection interrupted` message, and client-side `BrokenPipeError` disconnects no longer get logged as server 500s. +- Clarify `Response interrupted` recovery markers so they report that the live response stream stopped instead of asserting that the WebUI process restarted. The recovery path now records distinct interruption causes for real process restarts, stream/run split-brain, and lost worker bookkeeping; browser-side SSE transport failures show a separate `Connection interrupted` message, client-side `BrokenPipeError` disconnects no longer get logged as server 500s, and chat/gateway SSE errors emit rate-limited, body-capped, sanitized client diagnostics to `/api/client-events/log` for future root-cause checks. The stream-status `terminal_state` value for lost-worker bookkeeping changes from `stale-from-restart` to `lost-worker-bookkeeping`, matching the new non-restart wording. ## [v0.51.131] — 2026-05-24 — Release DC (stage-batch13 — 6-PR notes-drawer + context-parity + PWA-swipe + locale polish) diff --git a/api/routes.py b/api/routes.py index 8edc587293..fe789dea55 100644 --- a/api/routes.py +++ b/api/routes.py @@ -23,7 +23,7 @@ import re from pathlib import Path from contextlib import closing -from urllib.parse import parse_qs +from urllib.parse import parse_qs, urlsplit from api.agent_sessions import ( MESSAGING_SOURCES, is_cli_session_row, @@ -75,6 +75,21 @@ _CSP_REPORT_RATE_LIMIT_WINDOW_SECONDS = 60 _CSP_REPORT_RATE_LIMIT_MAX = 100 _CSP_REPORT_MAX_BODY_BYTES = 64 * 1024 +_CLIENT_EVENT_LOGGER = logging.getLogger("client_event") +_CLIENT_EVENT_RATE_LIMIT: dict[str, list[float]] = {} +_CLIENT_EVENT_RATE_LIMIT_LOCK = threading.Lock() +_CLIENT_EVENT_RATE_LIMIT_WINDOW_SECONDS = 60 +_CLIENT_EVENT_RATE_LIMIT_MAX = 30 +_CLIENT_EVENT_MAX_BODY_BYTES = 4 * 1024 +_CLIENT_EVENT_ALLOWED_FIELDS = { + "event": 64, + "source": 80, + "session_id": 128, + "stream_id": 128, + "visibility_state": 32, + "url_path": 256, + "reason": 160, +} def _session_field(session, field, default=None): @@ -1371,6 +1386,20 @@ def _csp_report_rate_limited(handler, *, now: float | None = None) -> bool: return False +def _client_event_rate_limited(handler, *, now: float | None = None) -> bool: + now = time.time() if now is None else now + key = _client_ip_for_rate_limit(handler) + cutoff = now - _CLIENT_EVENT_RATE_LIMIT_WINDOW_SECONDS + with _CLIENT_EVENT_RATE_LIMIT_LOCK: + timestamps = [ts for ts in _CLIENT_EVENT_RATE_LIMIT.get(key, []) if ts >= cutoff] + if len(timestamps) >= _CLIENT_EVENT_RATE_LIMIT_MAX: + _CLIENT_EVENT_RATE_LIMIT[key] = timestamps + return True + timestamps.append(now) + _CLIENT_EVENT_RATE_LIMIT[key] = timestamps + return False + + def _send_no_content(handler, status: int = 204) -> bool: handler.send_response(status) handler.send_header("Content-Length", "0") @@ -1410,6 +1439,105 @@ def _handle_csp_report(handler) -> bool: return _send_no_content(handler) +def _bounded_client_event_string(value, limit: int) -> str | None: + if value is None: + return None + text = str(value).strip() + if not text: + return None + return text[:limit] + + +def _sanitize_client_event_url_path(value) -> str | None: + text = _bounded_client_event_string(value, 1024) + if not text: + return None + try: + parsed = urlsplit(text) + path = parsed.path or "/" + except Exception: + path = text.split("?", 1)[0] or "/" + if not path.startswith("/"): + path = "/" + path.lstrip("/") + return path[: _CLIENT_EVENT_ALLOWED_FIELDS["url_path"]] + + +def _sanitize_client_event_payload(payload: dict | None) -> dict: + """Whitelist tiny browser diagnostic events and discard sensitive content. + + Client-side SSE diagnostics should explain transport failures without + persisting prompts, cookies, query strings, headers, or arbitrary browser + payloads. This helper intentionally keeps only bounded scalar metadata. + """ + if not isinstance(payload, dict): + return {"event": "unknown"} + sanitized: dict[str, object] = {} + for field, limit in _CLIENT_EVENT_ALLOWED_FIELDS.items(): + if field == "url_path": + value = _sanitize_client_event_url_path(payload.get(field)) + else: + value = _bounded_client_event_string(payload.get(field), limit) + if value is not None: + sanitized[field] = value + ready_state = payload.get("ready_state") + if isinstance(ready_state, bool): + pass + elif isinstance(ready_state, int) and 0 <= ready_state <= 3: + sanitized["ready_state"] = ready_state + online = payload.get("online") + if isinstance(online, bool): + sanitized["online"] = online + elif isinstance(online, str): + lowered = online.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + sanitized["online"] = True + elif lowered in {"false", "0", "no", "off"}: + sanitized["online"] = False + if "event" not in sanitized: + sanitized["event"] = "unknown" + return sanitized + + +def _read_client_event_payload(handler) -> dict: + try: + length = int(handler.headers.get("Content-Length", 0)) + except Exception: + length = 0 + if length > _CLIENT_EVENT_MAX_BODY_BYTES: + try: + handler.rfile.read(_CLIENT_EVENT_MAX_BODY_BYTES) + except Exception: + pass + # Do not leave unread request-body bytes on an HTTP/1.1 keep-alive + # socket. Draining an arbitrary oversized body can tie up a worker; + # closing the connection after the bounded read preserves framing for + # the next request without turning diagnostics into a slow-drain sink. + try: + handler.close_connection = True + except Exception: + pass + return {"event": "discarded", "reason": "body_too_large"} + raw = handler.rfile.read(length) if length else b"{}" + try: + decoded = raw.decode("utf-8") + payload = json.loads(decoded) + except Exception: + return {"event": "invalid", "reason": "invalid_json"} + return payload if isinstance(payload, dict) else {"event": "invalid", "reason": "not_object"} + + +def _handle_client_event_log(handler, body: dict) -> bool: + if _client_event_rate_limited(handler): + _CLIENT_EVENT_LOGGER.warning( + "Dropped client event from %s: rate limit exceeded", + _client_ip_for_rate_limit(handler), + ) + return j(handler, {"ok": False, "error": "rate_limited"}, status=429) or True + payload = _sanitize_client_event_payload(body) + _CLIENT_EVENT_LOGGER.info("Client event from %s: %s", _client_ip_for_rate_limit(handler), payload) + return j(handler, {"ok": True, "event": payload.get("event")}) or True + + def _normalize_provider_id(value: str | None) -> str: raw = str(value or "").strip().lower() if not raw: @@ -4708,6 +4836,11 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/transcribe": return handle_transcribe(handler) + if parsed.path == "/api/client-events/log": + if diag: + diag.stage("read_client_event_body") + return _handle_client_event_log(handler, _read_client_event_payload(handler)) + if diag: diag.stage("read_body") try: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 2230b52e5a..1984ff1843 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -93,7 +93,7 @@ If after running steps 1-4 the import still fails *and* `pip install -e .` succe **Interruption classes.** The WebUI now keeps the user-facing cases separate instead of implying every stale stream was a restart: -- **Browser/SSE connection interrupted** — the live browser `EventSource` transport dropped. The UI reports `Connection interrupted` and tries status/replay/session restore before showing the final browser-side notice. +- **Browser/SSE connection interrupted** — the live browser `EventSource` transport dropped. The UI reports `Connection interrupted` and tries status/replay/session restore before showing the final browser-side notice. Chat and gateway SSE errors also POST a small sanitized diagnostic event to `/api/client-events/log` (source, session id, stream id, readyState, visibility, online state, path without query string) so server logs can distinguish browser transport loss from backend worker loss. - **Lost worker bookkeeping** — the stream id is gone and the worker registry no longer has an active run. Recovery markers carry `interruption_cause: "lost_worker_bookkeeping"` and `/api/chat/stream/status` reports `terminal_state: "lost-worker-bookkeeping"` for non-terminal journals that are no longer active. - **Stream/run split-brain** — the stream is gone but `ACTIVE_RUNS` still lists the worker. Recovery markers carry `interruption_cause: "stream_run_split_brain"` so the transcript says this is a bookkeeping split-brain rather than a restart. - **Process crash/restart** — `SERVER_START_TIME` is newer than `pending_started_at`, meaning the WebUI process started after the turn began. Recovery markers carry `interruption_cause: "process_restart"` and explicitly say the process-start evidence points to a crash or restart. diff --git a/static/messages.js b/static/messages.js index 7e55b7bb89..f10b5d9d12 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2080,13 +2080,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }); source.addEventListener('error',async e=>{ - source.close(); - if(_deferStreamErrorIfOffline()) return; - if(_deferStreamErrorIfPageHidden()) return; if(_terminalStateReached || _streamFinalized){ _closeSource(); return; } + if(typeof recordClientSSEError==='function') recordClientSSEError('chat-response',{ready_state:source?source.readyState:null,session_id:activeSid,stream_id:streamId,reason:'chat EventSource.onerror'}); + source.close(); + if(_deferStreamErrorIfOffline()) return; + if(_deferStreamErrorIfPageHidden()) return; // Attempt one reconnect if the stream is still active server-side if(!_reconnectAttempted && streamId){ _reconnectAttempted=true; diff --git a/static/sessions.js b/static/sessions.js index f57b5d9c02..83d8c5c4e7 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2438,6 +2438,7 @@ function startGatewaySSE(){ }catch(e){ /* ignore parse errors */ } }); _gatewaySSE.onerror = () => { + if(typeof recordClientSSEError==='function') recordClientSSEError('gateway-sessions',{ready_state:_gatewaySSE?_gatewaySSE.readyState:null,reason:'gateway EventSource.onerror'}); if(_gatewaySSE){ _gatewaySSE.close(); _gatewaySSE = null; diff --git a/static/workspace.js b/static/workspace.js index f5972d067f..7562c0f07f 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -85,6 +85,23 @@ async function api(path,opts={}){ throw lastErr; } +function recordClientSSEError(source, details={}){ + try{ + const payload={ + event:'sse_error', + source:String(source||'unknown'), + ready_state:details.ready_state, + session_id:details.session_id||null, + stream_id:details.stream_id||null, + visibility_state:(typeof document!=='undefined'&&document.visibilityState)||'unknown', + online:(typeof navigator!=='undefined'&&typeof navigator.onLine==='boolean')?navigator.onLine:null, + url_path:(typeof location!=='undefined'&&location.pathname)||'/', + reason:details.reason||'EventSource.onerror', + }; + void api('/api/client-events/log',{method:'POST',body:JSON.stringify(payload),timeoutMs:3000}).catch(()=>{}); + }catch(_){} +} + // Persist/restore expanded directory state per workspace in localStorage function _wsExpandKey(){ const ws=S.session&&S.session.workspace; diff --git a/tests/test_client_event_diagnostics.py b/tests/test_client_event_diagnostics.py new file mode 100644 index 0000000000..e7de46dc6b --- /dev/null +++ b/tests/test_client_event_diagnostics.py @@ -0,0 +1,158 @@ +"""Regression coverage for browser-side SSE disconnect diagnostics. + +When an EventSource fails in the browser, the server normally only sees a dead +socket or follow-up probe. Persisting a small, sanitized client event makes the +next incident diagnosable without logging prompt text or credentials. +""" +from pathlib import Path +from io import BytesIO +from types import SimpleNamespace + +import api.routes as routes + + +REPO = Path(__file__).resolve().parents[1] +WORKSPACE_JS = (REPO / "static" / "workspace.js").read_text(encoding="utf-8") +SESSIONS_JS = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") +MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") + + +def test_client_event_log_sanitizes_and_whitelists_fields(): + payload = { + "event": "sse_error", + "source": "gateway-sessions", + "session_id": "abc123", + "stream_id": "stream456", + "ready_state": 2, + "visibility_state": "hidden", + "online": True, + "url_path": "/chat/abc123?debug=1", + "reason": "onerror", + "password": "must-not-survive", + "content": "prompt text must not survive", + "cookie": "session=secret", + } + + sanitized = routes._sanitize_client_event_payload(payload) + + assert sanitized == { + "event": "sse_error", + "source": "gateway-sessions", + "session_id": "abc123", + "stream_id": "stream456", + "ready_state": 2, + "visibility_state": "hidden", + "online": True, + "url_path": "/chat/abc123", + "reason": "onerror", + } + assert "secret" not in repr(sanitized) + assert "prompt text" not in repr(sanitized) + + +def test_client_event_log_bounds_untrusted_values(): + payload = { + "event": "x" * 200, + "source": "chat" * 100, + "session_id": "s" * 200, + "stream_id": "t" * 200, + "ready_state": "not-a-number", + "visibility_state": "visible" * 40, + "online": "yes", + "url_path": "https://example.invalid/path?debug=1", + "reason": "network" * 80, + } + + sanitized = routes._sanitize_client_event_payload(payload) + + assert sanitized["event"] == "x" * 64 + assert len(sanitized["source"]) == 80 + assert len(sanitized["session_id"]) == 128 + assert len(sanitized["stream_id"]) == 128 + assert "ready_state" not in sanitized + assert sanitized["visibility_state"] == ("visible" * 40)[:32] + assert sanitized["online"] is True + assert sanitized["url_path"] == "/path" + assert len(sanitized["reason"]) == 160 + + +def test_client_event_log_route_is_wired(monkeypatch): + captured = {} + + body_bytes = b'{"event":"sse_error","source":"chat-response"}' + + def fake_handle(handler, body): + captured["body"] = body + return True + + monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) + monkeypatch.setattr(routes, "_handle_client_event_log", fake_handle) + + handler = SimpleNamespace(headers={"Content-Length": str(len(body_bytes))}, rfile=BytesIO(body_bytes)) + handled = routes.handle_post(handler, SimpleNamespace(path="/api/client-events/log")) + + assert handled is True + assert captured["body"] == {"event": "sse_error", "source": "chat-response"} + + +def test_client_event_log_has_endpoint_specific_body_cap(): + too_large = b"{" + b"x" * (routes._CLIENT_EVENT_MAX_BODY_BYTES + 32) + b"}" + handler = SimpleNamespace( + headers={"Content-Length": str(len(too_large))}, + rfile=BytesIO(too_large), + close_connection=False, + ) + + payload = routes._read_client_event_payload(handler) + + assert payload == {"event": "discarded", "reason": "body_too_large"} + assert handler.rfile.tell() == routes._CLIENT_EVENT_MAX_BODY_BYTES + assert handler.close_connection is True + + +def test_client_event_log_rate_limits_per_client(monkeypatch): + routes._CLIENT_EVENT_RATE_LIMIT.clear() + monkeypatch.setattr(routes, "j", lambda handler, payload, status=200: payload.update({"status": status}) or True) + handler = SimpleNamespace(client_address=("203.0.113.10", 1234)) + + for i in range(routes._CLIENT_EVENT_RATE_LIMIT_MAX): + assert routes._handle_client_event_log(handler, {"event": f"sse_error_{i}"}) is True + + limited = {} + monkeypatch.setattr(routes, "j", lambda handler, payload, status=200: limited.update(payload, status=status) or True) + assert routes._handle_client_event_log(handler, {"event": "sse_error_over_limit"}) is True + + assert limited == {"ok": False, "error": "rate_limited", "status": 429} + routes._CLIENT_EVENT_RATE_LIMIT.clear() + + +def test_workspace_js_defines_sanitized_client_sse_error_reporter(): + helper_start = WORKSPACE_JS.index("function recordClientSSEError") + helper_block = WORKSPACE_JS[helper_start:helper_start + 900] + assert "api/client-events/log" in helper_block + assert "document.visibilityState" in helper_block + assert "navigator.onLine" in helper_block + assert "location.pathname" in helper_block + assert "location.search" not in helper_block + + +def test_sessions_js_reports_gateway_sse_errors_with_browser_context(): + gateway_block_start = SESSIONS_JS.index("_gatewaySSE.onerror = () =>") + gateway_block = SESSIONS_JS[gateway_block_start:gateway_block_start + 400] + assert "recordClientSSEError('gateway-sessions'" in gateway_block + assert "probeGatewaySSEStatus" in gateway_block + + +def test_messages_js_reports_chat_sse_errors_with_stream_identity(): + error_block_start = MESSAGES_JS.index("source.addEventListener('error',async e=>") + error_block = MESSAGES_JS[error_block_start:error_block_start + 900] + assert "recordClientSSEError('chat-response'" in error_block + assert "session_id:activeSid" in error_block + assert "stream_id:streamId" in error_block + + +def test_messages_js_keeps_finalized_stream_guard_before_diagnostic_report(): + error_block_start = MESSAGES_JS.index("source.addEventListener('error',async e=>") + error_block = MESSAGES_JS[error_block_start:error_block_start + 900] + assert "_streamFinalized" in error_block + assert error_block.index("_streamFinalized") < error_block.index("recordClientSSEError") From 15cde132f3b8ea757987f600b74fdba69ce6ab9a Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Thu, 21 May 2026 05:42:19 +0000 Subject: [PATCH 026/349] fix: dedupe replayed context summaries --- api/streaming.py | 79 +++++++++++++++++-- tests/test_issue1217_transcript_compaction.py | 61 ++++++++++++++ 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index db6488030f..35233482aa 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2399,8 +2399,68 @@ def _strip_replayed_prefix(existing_messages, candidates): return candidates -def _dedupe_replayed_active_context(previous_context, result_messages): - """Keep model context append-only without re-appending a replayed tail.""" +def _looks_like_replayed_session_arc_summary(previous_msg, candidate_msg): + """Return True for repeated LCM/session summaries with refreshed hints. + + LCM summary cards can be re-injected with the same long recovered context + and a different tail such as an expand hint. Exact identity misses those, + but appending both copies bloats every later model prompt. + """ + if not isinstance(previous_msg, dict) or not isinstance(candidate_msg, dict): + return False + if previous_msg.get('role') != candidate_msg.get('role'): + return False + previous_text = " ".join(_message_text(previous_msg.get('content', '')).split()) + candidate_text = " ".join(_message_text(candidate_msg.get('content', '')).split()) + if len(previous_text) < 2000 or len(candidate_text) < 2000: + return False + marker = '[Session Arc Summary' + if not previous_text.startswith(marker) or not candidate_text.startswith(marker): + return False + return previous_text[:1500] == candidate_text[:1500] + + +def _strip_replayed_context_items(existing_messages, candidates): + """Drop replayed non-adjacent context blocks before persisting context.""" + existing_messages = list(existing_messages or []) + candidates = list(candidates or []) + if not existing_messages or not candidates: + return candidates + + existing_keys = [_message_replay_key(m) for m in existing_messages] + candidate_keys = [_message_replay_key(m) for m in candidates] + existing_large = [m for m in existing_messages if isinstance(m, dict)] + cleaned = [] + idx = 0 + min_block = 3 + while idx < len(candidates): + msg = candidates[idx] + if any(_looks_like_replayed_session_arc_summary(prev, msg) for prev in existing_large): + idx += 1 + continue + + best = 0 + for start in range(len(existing_keys)): + length = 0 + while ( + idx + length < len(candidate_keys) + and start + length < len(existing_keys) + and candidate_keys[idx + length] == existing_keys[start + length] + ): + length += 1 + if length > best: + best = length + if best >= min_block: + idx += best + continue + + cleaned.append(msg) + idx += 1 + return cleaned + + +def _dedupe_replayed_context_messages(previous_context, result_messages): + """Keep model context append-only without replayed blocks/summaries.""" previous_context = list(previous_context or []) result_messages = list(result_messages or []) if not previous_context or not result_messages: @@ -2408,7 +2468,14 @@ def _dedupe_replayed_active_context(previous_context, result_messages): if not _messages_have_prefix(result_messages, previous_context): return result_messages candidates = result_messages[len(previous_context):] - return previous_context + _strip_replayed_prefix(previous_context, candidates) + candidates = _strip_replayed_prefix(previous_context, candidates) + candidates = _strip_replayed_context_items(previous_context, candidates) + return previous_context + candidates + + +def _dedupe_replayed_active_context(previous_context, result_messages): + """Keep model context append-only without re-appending a replayed tail.""" + return _dedupe_replayed_context_messages(previous_context, result_messages) def _is_context_compression_marker(msg): @@ -4576,7 +4643,7 @@ def _periodic_checkpoint(): _previous_context_messages, _result_messages, ) - _next_context_messages = _dedupe_replayed_active_context( + _next_context_messages = _dedupe_replayed_context_messages( _previous_context_messages, _next_context_messages, ) @@ -4723,7 +4790,7 @@ def _periodic_checkpoint(): _previous_context_messages, _result_messages, ) - _next_context_messages = _dedupe_replayed_active_context( + _next_context_messages = _dedupe_replayed_context_messages( _previous_context_messages, _next_context_messages, ) @@ -5549,7 +5616,7 @@ def _periodic_checkpoint(): _next_context_messages = _restore_reasoning_metadata( _previous_context_messages, _result_messages, ) - _next_context_messages = _dedupe_replayed_active_context( + _next_context_messages = _dedupe_replayed_context_messages( _previous_context_messages, _next_context_messages, ) diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index 4549cb823f..ab35a6d7b5 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -6,6 +6,7 @@ _assistant_reply_added_after_current_turn, _context_messages_for_new_turn, _dedupe_replayed_active_context, + _dedupe_replayed_context_messages, _merge_display_messages_after_agent_result, _new_turn_context_from_messages, _sanitize_messages_for_api, @@ -310,6 +311,66 @@ def test_repeated_user_text_after_compaction_is_not_dropped(): ] +def test_near_duplicate_session_arc_summary_is_not_replayed_in_context(): + summary_a = { + "role": "user", + "content": "[Session Arc Summary (d1, node 39)]\n" + + "same recovered LCM context\n" * 260 + + "expand hint: old node", + } + summary_b = { + "role": "user", + "content": "[Session Arc Summary (d1, node 39)]\n" + + "same recovered LCM context\n" * 260 + + "expand hint: refreshed node", + } + previous_context = [summary_a, {"role": "user", "content": "choose agent"}] + result_messages = previous_context + [ + {"role": "assistant", "content": "agent answer"}, + summary_b, + {"role": "user", "content": "next question"}, + ] + + next_context = _dedupe_replayed_context_messages(previous_context, result_messages) + + assert [m["content"] for m in next_context] == [ + summary_a["content"], + "choose agent", + "agent answer", + "next question", + ] + + +def test_non_adjacent_replayed_context_block_is_not_appended_again(): + previous_context = [ + {"role": "user", "content": "older setup"}, + {"role": "user", "content": "choose agent"}, + {"role": "assistant", "content": "checking agents"}, + {"role": "tool", "content": "agent list"}, + {"role": "assistant", "content": "agent answer"}, + ] + result_messages = previous_context + [ + {"role": "assistant", "content": "[CONTEXT COMPACTION — REFERENCE ONLY] compacted"}, + {"role": "user", "content": "choose agent"}, + {"role": "assistant", "content": "checking agents"}, + {"role": "tool", "content": "agent list"}, + {"role": "assistant", "content": "agent answer"}, + {"role": "user", "content": "next question"}, + ] + + next_context = _dedupe_replayed_context_messages(previous_context, result_messages) + + assert [m["content"] for m in next_context] == [ + "older setup", + "choose agent", + "checking agents", + "agent list", + "agent answer", + "[CONTEXT COMPACTION — REFERENCE ONLY] compacted", + "next question", + ] + + def test_session_context_falls_back_to_display_messages_for_legacy_sessions(tmp_path): messages = [ {"role": "user", "content": "legacy prompt"}, From 50c69713cc8de4d755058d4fb917ff475ab0fafb Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Thu, 21 May 2026 06:47:44 +0000 Subject: [PATCH 027/349] fix: reconcile state db delta after context --- api/models.py | 54 +++++++ pr-artifacts/context-replay-failure-cases.md | 143 ++++++++++++++++++ tests/test_issue1217_transcript_compaction.py | 131 ++++++++++++++++ 3 files changed, 328 insertions(+) create mode 100644 pr-artifacts/context-replay-failure-cases.md diff --git a/api/models.py b/api/models.py index fd6161ec57..909f3f61dc 100644 --- a/api/models.py +++ b/api/models.py @@ -3235,6 +3235,58 @@ def _has_visible_duplicate(visible_key: tuple, visible_keys: set[tuple]) -> bool return _matching_visible_duplicate(visible_key, visible_keys) is not None +def state_db_delta_after_context(sidecar_context: list, state_messages: list) -> list: + """Return only state.db rows that are newer than model-facing context. + + `context_messages` is the authoritative model-facing prefix. state.db may + contain a mirrored copy of that prefix with fresh timestamps, especially for + LCM/continuation sessions. Appending the whole state transcript to a clean + sidecar context replays old context into the next runtime prompt. + """ + sidecar_context = list(sidecar_context or []) + state_messages = list(state_messages or []) + if not sidecar_context or not state_messages: + return state_messages + + sidecar_keys = [_session_message_content_key(m) for m in sidecar_context] + state_keys = [_session_message_content_key(m) for m in state_messages] + max_offset = min(len(sidecar_keys), len(state_keys)) + best_len = 0 + for offset in range(max_offset): + length = 0 + while ( + offset + length < len(sidecar_keys) + and length < len(state_keys) + and sidecar_keys[offset + length] == state_keys[length] + ): + length += 1 + if length > best_len: + best_len = length + + # Require at least two mirrored rows. A single repeated short user message + # is not enough evidence that state.db starts with a mirrored context + # segment, but small recovered contexts often contain only a compact summary + # and one follow-up row; those should still use the delta path. + if best_len < 2: + return state_messages + + sidecar_key_set = set(sidecar_keys) + last_represented_state_index = best_len - 1 + for idx, key in enumerate(state_keys[best_len:], start=best_len): + if key in sidecar_key_set: + last_represented_state_index = idx + + delta = [] + for msg, key in zip( + state_messages[last_represented_state_index + 1:], + state_keys[last_represented_state_index + 1:], + ): + if key in sidecar_key_set: + continue + delta.append(msg) + return delta + + def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list: """Merge sidecar/context and state.db messages without deleting local rows.""" sidecar_messages = list(sidecar_messages or []) @@ -3340,6 +3392,8 @@ def reconciled_state_db_messages_for_session( local_messages = getattr(session, 'messages', None) or [] if state_messages is None: state_messages = get_state_db_session_messages(getattr(session, 'session_id', None)) + if prefer_context and local_messages: + state_messages = state_db_delta_after_context(local_messages, state_messages) return merge_session_messages_append_only(local_messages, state_messages) diff --git a/pr-artifacts/context-replay-failure-cases.md b/pr-artifacts/context-replay-failure-cases.md new file mode 100644 index 0000000000..3901a45963 --- /dev/null +++ b/pr-artifacts/context-replay-failure-cases.md @@ -0,0 +1,143 @@ +# Context replay / progress-ring failure cases + +This artifact records concrete failure cases observed while debugging WebUI + LCM context replay. It is intended to support an upstream PR with reproducible rationale. + +## A. Compression continuation replays active tail into display/context + +Observed shape after compression/continuation: + +```text +previous_context = [summary, A, B, C] +result_messages = previous_context + [A, B, C, D] +old saved context/display = [summary, A, B, C, A, B, C, D] +expected = [summary, A, B, C, D] +``` + +Code-level cause: writeback assumed `result_messages[len(previous_context):]` was all new delta. After LCM/session rollover, the agent may replay the active tail after the compacted prefix, so this assumption is false. + +Regression target: strip candidate prefixes that are already suffixes of existing context/display. + +## B. Near-duplicate large Session Arc Summary cards + +Observed in WebUI sidecar display transcript for sessions in the `20260520_200424_a43cef` / `20260520_201320_a95eac` lineage: + +```text +[Session Arc Summary (d1, node 39)] ... 62k chars +[Session Arc Summary (d1, node 39)] ... 62k chars +[Session Arc Summary (d1, node 39)] ... 74k chars +``` + +These summaries shared thousands of identical prefix characters but differed in tails/expand hints. Exact identity checks missed them. One duplicated ~80k char summary explains a ~20k-token jump. + +Regression target: treat large `[Session Arc Summary ...]` messages with the same long prefix as replayed summary artifacts. + +## C. Non-adjacent replay blocks separated by markers/summaries + +Observed display transcript contained repeated blocks that were not immediately adjacent, e.g. best block lengths around 171 messages in historical `messages`: + +```text +A B C ... [compression marker / summary / unrelated rows] ... A B C +``` + +Adjacent-only dedupe falsely reported clean. This matters because LCM/continuation can insert compression cards, cron banners, or summary messages between original block and replayed tail. + +Regression target: detect and strip replayed non-adjacent blocks when appending model context candidates. + +## D. Non-streaming `/api/chat` writeback missed dedupe + +Observed session: `20260521_060755_294aed`. + +User asked a short question with no meaningful new tool usage: + +```text +这是一个内部服务对么?简答 +``` + +Before cleanup: + +```text +context_messages: 136 +best replay: 67 messages repeated from index 0 at index 67 +last_prompt_tokens: 136668 (~53.4% of 256k) +``` + +Expected shape was: + +```text +previous_context(67) + new_user + new_assistant = 69 messages +``` + +Actual cause: streaming writeback used `_dedupe_replayed_context_messages`, but synchronous `/api/chat` wrote `_restore_reasoning_metadata(previous_context, result_messages)` directly to `s.context_messages`. + +Regression target: both streaming and non-streaming writeback paths must use the same replay-dedupe guard. + +## E. Runtime/progress-ring jump: clean persisted context, polluted turn-start reconciliation + +Observed session: `20260521_060755_294aed` after cleanup and deployment. + +Persisted sidecar after pause/cancel: + +```text +context_messages: 69 +context chars: 199,972 +rough content tokens: ~49,993 +last_prompt_tokens: 86,723 (~33.9%) +``` + +But starting/continuing a streaming turn made the progress ring jump to ~55%. Simulating the turn-start code path showed: + +```text +ctx_before_agent: 154 messages +chars: 448,438 +rough content tokens: ~112,109 +``` + +After applying existing final-writeback dedupe to that runtime prompt: + +```text +after_current_dedupe: 85 messages +chars: 248,466 +rough content tokens: ~62,116 +``` + +So the ring was not randomly wrong: it reflected a polluted runtime prompt estimate. The persisted sidecar stayed clean because final writeback/cancel did not save the runtime replay. + +Code-level cause: streaming turn start does: + +```python +_previous_context_messages = _new_turn_context_from_messages( + reconciled_state_db_messages_for_session( + s, + prefer_context=True, + state_messages=_external_state_messages, + ), + msg_text, +) +``` + +When `prefer_context=True`, sidecar `context_messages` are clean, but `state.db` still contains mirrored/replayed transcript rows. `reconciled_state_db_messages_for_session` append-only merges `context_messages + whole state transcript`, so the agent/runtime prompt temporarily receives old transcript rows again. + +Regression target: when `prefer_context=True` and sidecar `context_messages` exists, reconciliation must return: + +```text +clean sidecar context + truly newer state.db delta +``` + +not: + +```text +clean sidecar context + full state.db transcript +``` + +## PR thesis + +The bug family is not a model behavior issue. It is a WebUI persistence/reconciliation invariant violation: + +> Model-facing context is append-only, but append candidates may contain replayed context due to LCM/session continuation/state-db mirroring. Every boundary that merges result/state messages into model context must strip replayed prefixes/blocks and must distinguish clean sidecar context from full display/state transcripts. + +Key invariant for upstream: + +```text +If context_messages exists, it is the authoritative model-facing prefix. +State/db/display histories may be fuller/noisier and should only contribute messages that are demonstrably newer than that prefix. +``` diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index ab35a6d7b5..e45e9d1178 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -371,6 +371,137 @@ def test_non_adjacent_replayed_context_block_is_not_appended_again(): ] + + +def test_prefer_context_reconcile_strips_state_db_mirrored_prefix(): + sidecar_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "[Session Arc Summary]"}, + {"role": "assistant", "content": "old answer"}, + {"role": "user", "content": "latest saved question", "timestamp": 200.0}, + {"role": "assistant", "content": "latest saved answer", "timestamp": 201.0}, + ] + state_messages = [ + {"role": "assistant", "content": "cron banner", "timestamp": 100.0}, + {"role": "user", "content": "[Session Arc Summary]", "timestamp": 101.0}, + {"role": "assistant", "content": "old answer", "timestamp": 102.0}, + {"role": "user", "content": "latest saved question", "timestamp": 200.0}, + {"role": "assistant", "content": "latest saved answer", "timestamp": 201.0}, + {"role": "user", "content": "continue", "timestamp": 300.0}, + ] + session = SimpleNamespace( + session_id="prefix-replay", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + [ + {"role": "user", "content": "continue", "timestamp": 300.0}, + ] + + +def test_prefer_context_reconcile_keeps_state_delta_when_no_mirrored_prefix(): + sidecar_context = [ + {"role": "user", "content": "summary"}, + {"role": "assistant", "content": "saved answer"}, + ] + state_messages = [ + {"role": "user", "content": "fresh question", "timestamp": 300.0}, + {"role": "assistant", "content": "fresh answer", "timestamp": 301.0}, + ] + session = SimpleNamespace( + session_id="fresh-delta", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + state_messages + + +def test_prefer_context_reconcile_strips_mirrored_rows_without_sidecar_timestamps(): + sidecar_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "summary"}, + {"role": "assistant", "content": "older answer"}, + {"role": "user", "content": "already saved"}, + ] + state_messages = [ + {"role": "assistant", "content": "cron banner", "timestamp": 100.0}, + {"role": "user", "content": "summary", "timestamp": 101.0}, + {"role": "assistant", "content": "older answer", "timestamp": 102.0}, + {"role": "user", "content": "already saved", "timestamp": 500.0}, + {"role": "user", "content": "new after sidecar", "timestamp": 600.0}, + ] + session = SimpleNamespace( + session_id="untimestamped-context", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + [ + {"role": "user", "content": "new after sidecar", "timestamp": 600.0}, + ] + + +def test_prefer_context_reconcile_starts_after_last_state_row_seen_in_context(): + sidecar_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "summary"}, + {"role": "assistant", "content": "old answer"}, + {"role": "assistant", "content": "later represented row"}, + ] + state_messages = [ + {"role": "assistant", "content": "cron banner", "timestamp": 100.0}, + {"role": "user", "content": "summary", "timestamp": 101.0}, + {"role": "assistant", "content": "old answer", "timestamp": 102.0}, + {"role": "tool", "content": "state-only stale tool", "timestamp": 103.0}, + {"role": "assistant", "content": "later represented row", "timestamp": 104.0}, + {"role": "user", "content": "new after represented boundary", "timestamp": 200.0}, + ] + session = SimpleNamespace( + session_id="represented-boundary", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + [ + {"role": "user", "content": "new after represented boundary", "timestamp": 200.0}, + ] + +def test_non_streaming_chat_writeback_dedupes_full_context_replay(): + previous_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "[Session Arc Summary (d1, node 39)]\n" + "old context\n" * 400}, + {"role": "assistant", "content": "previous answer"}, + ] + result_messages = previous_context + previous_context + [ + {"role": "user", "content": "simple follow-up"}, + {"role": "assistant", "content": "short answer"}, + ] + + next_context = _dedupe_replayed_context_messages(previous_context, result_messages) + + assert next_context == previous_context + [ + {"role": "user", "content": "simple follow-up"}, + {"role": "assistant", "content": "short answer"}, + ] + def test_session_context_falls_back_to_display_messages_for_legacy_sessions(tmp_path): messages = [ {"role": "user", "content": "legacy prompt"}, From c616c8e78892bac658c338312414219828c4168b Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Thu, 21 May 2026 07:08:00 +0000 Subject: [PATCH 028/349] fix: cap live tool prompt estimates --- api/streaming.py | 50 ++++++++++++++++++-- pr-artifacts/context-replay-failure-cases.md | 17 +++++++ tests/test_streaming_live_usage_estimate.py | 22 +++++++++ 3 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 tests/test_streaming_live_usage_estimate.py diff --git a/api/streaming.py b/api/streaming.py index 35233482aa..5081d70ed4 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2849,6 +2849,50 @@ def _assistant_reply_added_after_current_turn(result_messages, previous_context, _TOOL_RESULT_SNIPPET_MAX = 4000 +_LIVE_TOOL_PROMPT_DELTA_MAX = 12_000 + + +def _bounded_live_tool_prompt_delta(messages, *, cap: int = _LIVE_TOOL_PROMPT_DELTA_MAX) -> int: + """Return a bounded rough token delta for live tool metering. + + Tool-result callbacks can fire before the agent's next exact prompt accounting + is available. The live usage ring should show a conservative in-flight hint, + not replay a full large tool payload into `last_prompt_tokens`. + """ + if not messages: + return 0 + try: + from agent.model_metadata import estimate_messages_tokens_rough + delta = int(estimate_messages_tokens_rough(messages) or 0) + except Exception: + delta = 0 + if delta <= 0: + return 0 + return min(delta, int(cap or 0)) + + +def live_usage_prompt_estimate_after_tool_delta( + *, + base_prompt_tokens: int, + exact_prompt_tokens: int = 0, + messages=None, + cap: int = _LIVE_TOOL_PROMPT_DELTA_MAX, +) -> dict: + """Compute the live `last_prompt_tokens` estimate after a tool update. + + Exact compressor/provider prompt accounting wins. When no newer exact prompt + is available, add only a bounded live tool delta to the persisted base. + """ + base = int(base_prompt_tokens or 0) + exact = int(exact_prompt_tokens or 0) + if exact and exact != base: + return {'last_prompt_tokens': exact, 'estimated': False} + return { + 'last_prompt_tokens': base + _bounded_live_tool_prompt_delta(messages, cap=cap), + 'estimated': True, + } + + def _tool_result_snippet(raw, limit: int = _TOOL_RESULT_SNIPPET_MAX) -> str: """Extract a bounded result preview from a stored tool message payload.""" if limit <= 0: @@ -3380,11 +3424,7 @@ def _bump_live_prompt_estimate(messages) -> int: """Increment a rough next-prompt estimate from live tool activity.""" if not messages: return _live_prompt_estimate_tokens[0] - try: - from agent.model_metadata import estimate_messages_tokens_rough - _delta = int(estimate_messages_tokens_rough(messages) or 0) - except Exception: - _delta = 0 + _delta = _bounded_live_tool_prompt_delta(messages) if _delta > 0: _seed_live_prompt_estimate() _live_prompt_estimate_tokens[0] += _delta diff --git a/pr-artifacts/context-replay-failure-cases.md b/pr-artifacts/context-replay-failure-cases.md index 3901a45963..46a9a72cef 100644 --- a/pr-artifacts/context-replay-failure-cases.md +++ b/pr-artifacts/context-replay-failure-cases.md @@ -141,3 +141,20 @@ Key invariant for upstream: If context_messages exists, it is the authoritative model-facing prefix. State/db/display histories may be fuller/noisier and should only contribute messages that are demonstrably newer than that prefix. ``` + + +## F. Live metering over-counts large in-flight tool results after cancel/retry + +Observed after deploying the reconciliation fix and retrying `continue` in session `20260521_060755_294aed`: + +```text +persisted sidecar context: 69 messages, ~49,993 rough content tokens +state.db messages: 90 messages +state delta after context: 21 messages, ~21,226 rough content tokens +turn-start context after fix: 90 messages, ~71,219 rough content tokens +last_prompt_tokens persisted: 86,723 (~33.9%) +``` + +The previous full-transcript turn-start replay was gone, but the ring still jumped during the run. The new jump came from live metering, not persisted context reconciliation. The run executed several large `read_file` tool calls (5k / 17k / 13k chars). `_record_live_tool_complete()` fed each bounded preview into `_bump_live_prompt_estimate()`, which added the full rough tool-result tokens to `last_prompt_tokens` before any exact next-prompt accounting was available. Repeated cancel/retry makes this look like context replay even when final sidecar context remains clean. + +Regression target: live tool metering should be a conservative UI hint and must not inflate `last_prompt_tokens` by the full content of large in-flight tool results. Exact provider/compressor prompt accounting should still win when available. diff --git a/tests/test_streaming_live_usage_estimate.py b/tests/test_streaming_live_usage_estimate.py new file mode 100644 index 0000000000..57d87c08a2 --- /dev/null +++ b/tests/test_streaming_live_usage_estimate.py @@ -0,0 +1,22 @@ +from api.streaming import live_usage_prompt_estimate_after_tool_delta + + +def test_live_usage_estimate_caps_tool_delta_against_previous_prompt(): + usage = live_usage_prompt_estimate_after_tool_delta( + base_prompt_tokens=86_723, + exact_prompt_tokens=86_723, + messages=[{"role": "tool", "content": "x" * 80_000}], + ) + + assert usage["last_prompt_tokens"] <= 86_723 + 12_000 + assert usage["last_prompt_tokens"] < 120_000 + + +def test_live_usage_estimate_preserves_real_prompt_when_exact_prompt_advances(): + usage = live_usage_prompt_estimate_after_tool_delta( + base_prompt_tokens=86_723, + exact_prompt_tokens=136_000, + messages=[{"role": "tool", "content": "x" * 80_000}], + ) + + assert usage["last_prompt_tokens"] == 136_000 From 3740df5302b84ff82a91722bdfa57e267f5b99d5 Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Thu, 21 May 2026 07:57:13 +0000 Subject: [PATCH 029/349] docs: add context replay PR body --- pr-artifacts/pr-body-summary-replay-dedupe.md | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 pr-artifacts/pr-body-summary-replay-dedupe.md diff --git a/pr-artifacts/pr-body-summary-replay-dedupe.md b/pr-artifacts/pr-body-summary-replay-dedupe.md new file mode 100644 index 0000000000..f43e61b84b --- /dev/null +++ b/pr-artifacts/pr-body-summary-replay-dedupe.md @@ -0,0 +1,83 @@ +## Summary + +Follow-up to #2651. That PR fixed one replay boundary, but continued testing exposed the same context-invariant violation at additional WebUI merge/metering boundaries. + +This PR makes the replay protection context-engine agnostic: + +- strips replayed non-adjacent context blocks and near-duplicate large Session Arc Summary cards before writing model context +- applies the same replay guard to the non-streaming `/api/chat` writeback path +- treats `context_messages` as the authoritative model-facing prefix when reconciling sidecar state with `state.db`, appending only demonstrably new state rows +- caps live tool-result prompt estimates so the context ring does not treat large in-flight tool outputs as exact prompt growth + +LCM/continuation made these failures easy to reproduce, but the invariant is broader than LCM: + +> If `context_messages` exists, it is the authoritative model-facing prefix. `messages`/`state.db` may be fuller or noisier histories and should only contribute true deltas. Live usage estimates must not override exact prompt accounting. + +## Why this happens + +WebUI currently merges several histories that have different meanings: + +- `context_messages`: compact model-facing context for the next call +- `messages`: visible display transcript +- `state.db`: append-only runtime/session journal, including tool rows + +After compression/continuation, those sources can overlap. The old code sometimes treated append candidates as wholly new: + +```text +clean context_messages + whole state.db transcript +``` + +or: + +```text +previous_context + replayed_tail + new_delta +``` + +That reintroduced old summaries, tool rows, or active-tail messages into the next model context or into the live usage estimate. + +## Failure cases covered + +The detailed debugging artifact is in `pr-artifacts/context-replay-failure-cases.md`. The key cases are: + +1. **Compression continuation replays the active tail** + - `result_messages` can contain `previous_context + replayed_tail + new_delta`. + - Prefix slicing alone saves the replayed tail again. + +2. **Near-duplicate large Session Arc Summary cards** + - Large `[Session Arc Summary ...]` messages can share a huge prefix while differing in refreshed tails/hints. + - Exact-match dedupe misses them. + +3. **Non-adjacent replay blocks** + - Replayed blocks can be separated by compression markers/summaries/tool rows, so adjacent-only dedupe is insufficient. + +4. **Non-streaming `/api/chat` writeback missed the replay guard** + - The streaming path deduped context writeback; synchronous chat restored reasoning metadata and saved directly. + +5. **Turn-start state reconciliation polluted a clean sidecar context** + - With `prefer_context=True`, a clean sidecar context could still be followed by mirrored `state.db` transcript rows. + - The next runtime prompt grew even though persisted `context_messages` stayed compact. + +6. **Live metering over-counted large in-flight tool results** + - Tool callbacks can arrive before exact next-prompt accounting. + - The old live estimate added full rough tool-result tokens to `last_prompt_tokens`, causing context-ring jumps that disappeared after cancel/persisted refresh. + +## Implementation notes + +- `_dedupe_replayed_context_messages(...)` now handles non-adjacent replay blocks and large near-duplicate summary cards. +- `/api/chat` writeback calls the same context replay guard as streaming writeback. +- `state_db_delta_after_context(...)` uses `context_messages` as the authoritative prefix and only returns state rows after the last state row already represented by sidecar context. +- `_bounded_live_tool_prompt_delta(...)` bounds live-only tool estimate growth while preserving exact compressor/provider prompt accounting when available. + +## Test plan + +```bash +python -m pytest -q tests/test_streaming_live_usage_estimate.py tests/test_issue1217_transcript_compaction.py tests/test_session_save_mode.py +git diff --check +python -m compileall -q api/models.py api/streaming.py api/routes.py +``` + +Current local result: + +```text +43 passed +``` From 5934c2fe8a034a27565e90f4769e548a716320c0 Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Fri, 22 May 2026 09:48:28 +0000 Subject: [PATCH 030/349] fix: address context replay review feedback --- api/routes.py | 5 + api/streaming.py | 3 +- tests/test_issue1217_transcript_compaction.py | 121 +++++++++++++++++- 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/api/routes.py b/api/routes.py index fe789dea55..97db1f2e95 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9079,6 +9079,7 @@ def _handle_chat_sync(handler, body): session_id=s.session_id, ) from api.streaming import ( + _dedupe_replayed_context_messages, _merge_display_messages_after_agent_result, _restore_display_reasoning_metadata, _restore_reasoning_metadata, @@ -9129,6 +9130,10 @@ def _handle_chat_sync(handler, body): _previous_context_messages, _result_messages, ) + _next_context_messages = _dedupe_replayed_context_messages( + _previous_context_messages, + _next_context_messages, + ) s.context_messages = _next_context_messages s.messages = _merge_display_messages_after_agent_result( _previous_messages, diff --git a/api/streaming.py b/api/streaming.py index 5081d70ed4..70a0a87d58 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2469,7 +2469,8 @@ def _dedupe_replayed_context_messages(previous_context, result_messages): return result_messages candidates = result_messages[len(previous_context):] candidates = _strip_replayed_prefix(previous_context, candidates) - candidates = _strip_replayed_context_items(previous_context, candidates) + if candidates: + candidates = _strip_replayed_context_items(previous_context, candidates) return previous_context + candidates diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index e45e9d1178..7b870d61ee 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -1,7 +1,11 @@ -from api.models import Session, reconciled_state_db_messages_for_session import contextlib +import io +import json +import sys from types import SimpleNamespace +from api.models import Session, reconciled_state_db_messages_for_session + from api.streaming import ( _assistant_reply_added_after_current_turn, _context_messages_for_new_turn, @@ -426,6 +430,31 @@ def test_prefer_context_reconcile_keeps_state_delta_when_no_mirrored_prefix(): assert reconciled == sidecar_context + state_messages +def test_prefer_context_reconcile_strips_small_mirrored_context_prefix(): + sidecar_context = [ + {"role": "user", "content": "[Session Arc Summary] compacted"}, + {"role": "assistant", "content": "last compacted answer"}, + ] + state_messages = [ + {"role": "user", "content": "[Session Arc Summary] compacted", "timestamp": 100.0}, + {"role": "assistant", "content": "last compacted answer", "timestamp": 101.0}, + {"role": "user", "content": "fresh follow-up", "timestamp": 200.0}, + ] + session = SimpleNamespace( + session_id="small-context-prefix", + context_messages=sidecar_context, + messages=[], + ) + + reconciled = reconciled_state_db_messages_for_session( + session, prefer_context=True, state_messages=state_messages + ) + + assert reconciled == sidecar_context + [ + {"role": "user", "content": "fresh follow-up", "timestamp": 200.0}, + ] + + def test_prefer_context_reconcile_strips_mirrored_rows_without_sidecar_timestamps(): sidecar_context = [ {"role": "assistant", "content": "cron banner"}, @@ -502,6 +531,96 @@ def test_non_streaming_chat_writeback_dedupes_full_context_replay(): {"role": "assistant", "content": "short answer"}, ] +class _FakePostHandler: + def __init__(self): + self.status = None + self.headers = {} + self.body = bytearray() + self.wfile = self + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.headers[name] = value + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def json_body(self): + return json.loads(bytes(self.body).decode("utf-8")) + + +def test_handle_chat_sync_writeback_dedupes_full_context_replay(tmp_path, monkeypatch): + import api.config as config + import api.models as models + import api.routes as routes + + state_dir = tmp_path / "state" + session_dir = state_dir / "sessions" + session_dir.mkdir(parents=True) + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(models, "SESSION_INDEX_FILE", state_dir / "session_index.json") + monkeypatch.setattr(routes, "SESSION_INDEX_FILE", state_dir / "session_index.json") + monkeypatch.setattr(routes, "get_session", models.get_session) + monkeypatch.setattr(routes, "title_from", models.title_from) + monkeypatch.setattr(config, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) + monkeypatch.setattr(routes, "get_config", lambda: {"model": "test-model", "provider": "test-provider"}) + monkeypatch.setattr(routes, "resolve_trusted_workspace", lambda value: tmp_path) + monkeypatch.setattr(routes, "load_settings", lambda: {}) + monkeypatch.setattr(routes, "_resolve_cli_toolsets", lambda: []) + + previous_context = [ + {"role": "assistant", "content": "cron banner"}, + {"role": "user", "content": "[Session Arc Summary (d1, node 39)]\n" + "old context\n" * 400}, + {"role": "assistant", "content": "previous answer"}, + ] + session = Session( + session_id="sync_chat_replay", + workspace=str(tmp_path), + messages=list(previous_context), + context_messages=list(previous_context), + model="test-model", + model_provider="test-provider", + ) + session.save(touch_updated_at=False) + + replayed_result = previous_context + previous_context + [ + {"role": "user", "content": "simple follow-up"}, + {"role": "assistant", "content": "short answer"}, + ] + + class FakeAgent: + def __init__(self, **_kwargs): + pass + + def run_conversation(self, **_kwargs): + return { + "messages": replayed_result, + "final_response": "short answer", + "completed": True, + } + + monkeypatch.setitem(sys.modules, "run_agent", SimpleNamespace(AIAgent=FakeAgent)) + + handler = _FakePostHandler() + routes._handle_chat_sync( + handler, + {"session_id": session.session_id, "message": "simple follow-up", "workspace": str(tmp_path)}, + ) + + assert handler.status == 200 + reloaded = Session.load(session.session_id) + assert reloaded.context_messages == previous_context + [ + {"role": "user", "content": "simple follow-up"}, + {"role": "assistant", "content": "short answer"}, + ] + assert reloaded.context_messages.count(previous_context[0]) == 1 + + def test_session_context_falls_back_to_display_messages_for_legacy_sessions(tmp_path): messages = [ {"role": "user", "content": "legacy prompt"}, From 32658978a970abea0eb863fd36a18b6ef6056281 Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Fri, 22 May 2026 09:49:21 +0000 Subject: [PATCH 031/349] docs: refresh context replay test count --- pr-artifacts/pr-body-summary-replay-dedupe.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pr-artifacts/pr-body-summary-replay-dedupe.md b/pr-artifacts/pr-body-summary-replay-dedupe.md index f43e61b84b..56a44007c2 100644 --- a/pr-artifacts/pr-body-summary-replay-dedupe.md +++ b/pr-artifacts/pr-body-summary-replay-dedupe.md @@ -79,5 +79,5 @@ python -m compileall -q api/models.py api/streaming.py api/routes.py Current local result: ```text -43 passed +45 passed ``` From d0992730a91ca6d4adee7391e0b57a7743074db4 Mon Sep 17 00:00:00 2001 From: Lumen Yang Date: Fri, 22 May 2026 21:58:59 +0000 Subject: [PATCH 032/349] fix: preserve repeated state rows in replay delta --- api/models.py | 28 +++++++++---------- tests/test_issue1217_transcript_compaction.py | 22 ++++++++++++++- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/api/models.py b/api/models.py index 909f3f61dc..fcd12ff5cb 100644 --- a/api/models.py +++ b/api/models.py @@ -3270,21 +3270,19 @@ def state_db_delta_after_context(sidecar_context: list, state_messages: list) -> if best_len < 2: return state_messages - sidecar_key_set = set(sidecar_keys) - last_represented_state_index = best_len - 1 - for idx, key in enumerate(state_keys[best_len:], start=best_len): - if key in sidecar_key_set: - last_represented_state_index = idx - - delta = [] - for msg, key in zip( - state_messages[last_represented_state_index + 1:], - state_keys[last_represented_state_index + 1:], - ): - if key in sidecar_key_set: - continue - delta.append(msg) - return delta + # Drop only rows that can be aligned with the remaining sidecar context in + # order. This still tolerates stale state-only rows between mirrored context + # rows, but once the sidecar context is exhausted every later state row is a + # real delta, even if it repeats a short earlier message. + sidecar_index = best_len + state_index = best_len + while sidecar_index < len(sidecar_keys) and state_index < len(state_keys): + if state_keys[state_index] == sidecar_keys[sidecar_index]: + sidecar_index += 1 + state_index += 1 + if sidecar_index == len(sidecar_keys): + return state_messages[state_index:] + return state_messages[best_len:] def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list: diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index 7b870d61ee..eced9261e5 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -4,7 +4,7 @@ import sys from types import SimpleNamespace -from api.models import Session, reconciled_state_db_messages_for_session +from api.models import Session, reconciled_state_db_messages_for_session, state_db_delta_after_context from api.streaming import ( _assistant_reply_added_after_current_turn, @@ -513,6 +513,26 @@ def test_prefer_context_reconcile_starts_after_last_state_row_seen_in_context(): {"role": "user", "content": "new after represented boundary", "timestamp": 200.0}, ] + + +def test_state_db_delta_preserves_fresh_rows_before_repeated_context_message(): + sidecar_context = [ + {"role": "user", "content": "ok"}, + {"role": "assistant", "content": "ready"}, + ] + state_messages = [ + {"role": "user", "content": "ok", "timestamp": 1.0}, + {"role": "assistant", "content": "ready", "timestamp": 2.0}, + {"role": "user", "content": "fresh question", "timestamp": 3.0}, + {"role": "user", "content": "ok", "timestamp": 4.0}, + {"role": "assistant", "content": "after repeat", "timestamp": 5.0}, + ] + + delta = state_db_delta_after_context(sidecar_context, state_messages) + + assert [m["content"] for m in delta] == ["fresh question", "ok", "after repeat"] + + def test_non_streaming_chat_writeback_dedupes_full_context_replay(): previous_context = [ {"role": "assistant", "content": "cron banner"}, From fe6558efac77f0fc86a44ed3456fed133a3ec7f0 Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Mon, 25 May 2026 00:10:39 +0000 Subject: [PATCH 033/349] Stage-batch14: drop pr-artifacts/ scratchpad from #2685 cherry-picks The contributor used pr-artifacts/ as a working scratchpad during PR development. The real test count and failure-mode docs are already covered by inline test comments and CHANGELOG entries; this directory adds nothing for upstream readers. --- pr-artifacts/context-replay-failure-cases.md | 160 ------------------ pr-artifacts/pr-body-summary-replay-dedupe.md | 83 --------- 2 files changed, 243 deletions(-) delete mode 100644 pr-artifacts/context-replay-failure-cases.md delete mode 100644 pr-artifacts/pr-body-summary-replay-dedupe.md diff --git a/pr-artifacts/context-replay-failure-cases.md b/pr-artifacts/context-replay-failure-cases.md deleted file mode 100644 index 46a9a72cef..0000000000 --- a/pr-artifacts/context-replay-failure-cases.md +++ /dev/null @@ -1,160 +0,0 @@ -# Context replay / progress-ring failure cases - -This artifact records concrete failure cases observed while debugging WebUI + LCM context replay. It is intended to support an upstream PR with reproducible rationale. - -## A. Compression continuation replays active tail into display/context - -Observed shape after compression/continuation: - -```text -previous_context = [summary, A, B, C] -result_messages = previous_context + [A, B, C, D] -old saved context/display = [summary, A, B, C, A, B, C, D] -expected = [summary, A, B, C, D] -``` - -Code-level cause: writeback assumed `result_messages[len(previous_context):]` was all new delta. After LCM/session rollover, the agent may replay the active tail after the compacted prefix, so this assumption is false. - -Regression target: strip candidate prefixes that are already suffixes of existing context/display. - -## B. Near-duplicate large Session Arc Summary cards - -Observed in WebUI sidecar display transcript for sessions in the `20260520_200424_a43cef` / `20260520_201320_a95eac` lineage: - -```text -[Session Arc Summary (d1, node 39)] ... 62k chars -[Session Arc Summary (d1, node 39)] ... 62k chars -[Session Arc Summary (d1, node 39)] ... 74k chars -``` - -These summaries shared thousands of identical prefix characters but differed in tails/expand hints. Exact identity checks missed them. One duplicated ~80k char summary explains a ~20k-token jump. - -Regression target: treat large `[Session Arc Summary ...]` messages with the same long prefix as replayed summary artifacts. - -## C. Non-adjacent replay blocks separated by markers/summaries - -Observed display transcript contained repeated blocks that were not immediately adjacent, e.g. best block lengths around 171 messages in historical `messages`: - -```text -A B C ... [compression marker / summary / unrelated rows] ... A B C -``` - -Adjacent-only dedupe falsely reported clean. This matters because LCM/continuation can insert compression cards, cron banners, or summary messages between original block and replayed tail. - -Regression target: detect and strip replayed non-adjacent blocks when appending model context candidates. - -## D. Non-streaming `/api/chat` writeback missed dedupe - -Observed session: `20260521_060755_294aed`. - -User asked a short question with no meaningful new tool usage: - -```text -这是一个内部服务对么?简答 -``` - -Before cleanup: - -```text -context_messages: 136 -best replay: 67 messages repeated from index 0 at index 67 -last_prompt_tokens: 136668 (~53.4% of 256k) -``` - -Expected shape was: - -```text -previous_context(67) + new_user + new_assistant = 69 messages -``` - -Actual cause: streaming writeback used `_dedupe_replayed_context_messages`, but synchronous `/api/chat` wrote `_restore_reasoning_metadata(previous_context, result_messages)` directly to `s.context_messages`. - -Regression target: both streaming and non-streaming writeback paths must use the same replay-dedupe guard. - -## E. Runtime/progress-ring jump: clean persisted context, polluted turn-start reconciliation - -Observed session: `20260521_060755_294aed` after cleanup and deployment. - -Persisted sidecar after pause/cancel: - -```text -context_messages: 69 -context chars: 199,972 -rough content tokens: ~49,993 -last_prompt_tokens: 86,723 (~33.9%) -``` - -But starting/continuing a streaming turn made the progress ring jump to ~55%. Simulating the turn-start code path showed: - -```text -ctx_before_agent: 154 messages -chars: 448,438 -rough content tokens: ~112,109 -``` - -After applying existing final-writeback dedupe to that runtime prompt: - -```text -after_current_dedupe: 85 messages -chars: 248,466 -rough content tokens: ~62,116 -``` - -So the ring was not randomly wrong: it reflected a polluted runtime prompt estimate. The persisted sidecar stayed clean because final writeback/cancel did not save the runtime replay. - -Code-level cause: streaming turn start does: - -```python -_previous_context_messages = _new_turn_context_from_messages( - reconciled_state_db_messages_for_session( - s, - prefer_context=True, - state_messages=_external_state_messages, - ), - msg_text, -) -``` - -When `prefer_context=True`, sidecar `context_messages` are clean, but `state.db` still contains mirrored/replayed transcript rows. `reconciled_state_db_messages_for_session` append-only merges `context_messages + whole state transcript`, so the agent/runtime prompt temporarily receives old transcript rows again. - -Regression target: when `prefer_context=True` and sidecar `context_messages` exists, reconciliation must return: - -```text -clean sidecar context + truly newer state.db delta -``` - -not: - -```text -clean sidecar context + full state.db transcript -``` - -## PR thesis - -The bug family is not a model behavior issue. It is a WebUI persistence/reconciliation invariant violation: - -> Model-facing context is append-only, but append candidates may contain replayed context due to LCM/session continuation/state-db mirroring. Every boundary that merges result/state messages into model context must strip replayed prefixes/blocks and must distinguish clean sidecar context from full display/state transcripts. - -Key invariant for upstream: - -```text -If context_messages exists, it is the authoritative model-facing prefix. -State/db/display histories may be fuller/noisier and should only contribute messages that are demonstrably newer than that prefix. -``` - - -## F. Live metering over-counts large in-flight tool results after cancel/retry - -Observed after deploying the reconciliation fix and retrying `continue` in session `20260521_060755_294aed`: - -```text -persisted sidecar context: 69 messages, ~49,993 rough content tokens -state.db messages: 90 messages -state delta after context: 21 messages, ~21,226 rough content tokens -turn-start context after fix: 90 messages, ~71,219 rough content tokens -last_prompt_tokens persisted: 86,723 (~33.9%) -``` - -The previous full-transcript turn-start replay was gone, but the ring still jumped during the run. The new jump came from live metering, not persisted context reconciliation. The run executed several large `read_file` tool calls (5k / 17k / 13k chars). `_record_live_tool_complete()` fed each bounded preview into `_bump_live_prompt_estimate()`, which added the full rough tool-result tokens to `last_prompt_tokens` before any exact next-prompt accounting was available. Repeated cancel/retry makes this look like context replay even when final sidecar context remains clean. - -Regression target: live tool metering should be a conservative UI hint and must not inflate `last_prompt_tokens` by the full content of large in-flight tool results. Exact provider/compressor prompt accounting should still win when available. diff --git a/pr-artifacts/pr-body-summary-replay-dedupe.md b/pr-artifacts/pr-body-summary-replay-dedupe.md deleted file mode 100644 index 56a44007c2..0000000000 --- a/pr-artifacts/pr-body-summary-replay-dedupe.md +++ /dev/null @@ -1,83 +0,0 @@ -## Summary - -Follow-up to #2651. That PR fixed one replay boundary, but continued testing exposed the same context-invariant violation at additional WebUI merge/metering boundaries. - -This PR makes the replay protection context-engine agnostic: - -- strips replayed non-adjacent context blocks and near-duplicate large Session Arc Summary cards before writing model context -- applies the same replay guard to the non-streaming `/api/chat` writeback path -- treats `context_messages` as the authoritative model-facing prefix when reconciling sidecar state with `state.db`, appending only demonstrably new state rows -- caps live tool-result prompt estimates so the context ring does not treat large in-flight tool outputs as exact prompt growth - -LCM/continuation made these failures easy to reproduce, but the invariant is broader than LCM: - -> If `context_messages` exists, it is the authoritative model-facing prefix. `messages`/`state.db` may be fuller or noisier histories and should only contribute true deltas. Live usage estimates must not override exact prompt accounting. - -## Why this happens - -WebUI currently merges several histories that have different meanings: - -- `context_messages`: compact model-facing context for the next call -- `messages`: visible display transcript -- `state.db`: append-only runtime/session journal, including tool rows - -After compression/continuation, those sources can overlap. The old code sometimes treated append candidates as wholly new: - -```text -clean context_messages + whole state.db transcript -``` - -or: - -```text -previous_context + replayed_tail + new_delta -``` - -That reintroduced old summaries, tool rows, or active-tail messages into the next model context or into the live usage estimate. - -## Failure cases covered - -The detailed debugging artifact is in `pr-artifacts/context-replay-failure-cases.md`. The key cases are: - -1. **Compression continuation replays the active tail** - - `result_messages` can contain `previous_context + replayed_tail + new_delta`. - - Prefix slicing alone saves the replayed tail again. - -2. **Near-duplicate large Session Arc Summary cards** - - Large `[Session Arc Summary ...]` messages can share a huge prefix while differing in refreshed tails/hints. - - Exact-match dedupe misses them. - -3. **Non-adjacent replay blocks** - - Replayed blocks can be separated by compression markers/summaries/tool rows, so adjacent-only dedupe is insufficient. - -4. **Non-streaming `/api/chat` writeback missed the replay guard** - - The streaming path deduped context writeback; synchronous chat restored reasoning metadata and saved directly. - -5. **Turn-start state reconciliation polluted a clean sidecar context** - - With `prefer_context=True`, a clean sidecar context could still be followed by mirrored `state.db` transcript rows. - - The next runtime prompt grew even though persisted `context_messages` stayed compact. - -6. **Live metering over-counted large in-flight tool results** - - Tool callbacks can arrive before exact next-prompt accounting. - - The old live estimate added full rough tool-result tokens to `last_prompt_tokens`, causing context-ring jumps that disappeared after cancel/persisted refresh. - -## Implementation notes - -- `_dedupe_replayed_context_messages(...)` now handles non-adjacent replay blocks and large near-duplicate summary cards. -- `/api/chat` writeback calls the same context replay guard as streaming writeback. -- `state_db_delta_after_context(...)` uses `context_messages` as the authoritative prefix and only returns state rows after the last state row already represented by sidecar context. -- `_bounded_live_tool_prompt_delta(...)` bounds live-only tool estimate growth while preserving exact compressor/provider prompt accounting when available. - -## Test plan - -```bash -python -m pytest -q tests/test_streaming_live_usage_estimate.py tests/test_issue1217_transcript_compaction.py tests/test_session_save_mode.py -git diff --check -python -m compileall -q api/models.py api/streaming.py api/routes.py -``` - -Current local result: - -```text -45 passed -``` From 39121650d4a7c5ca233410df059275831fc8b70e Mon Sep 17 00:00:00 2001 From: gavinssr Date: Sun, 24 May 2026 15:34:26 +0800 Subject: [PATCH 034/349] feat: add shutdown button to WebUI title bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a power button (⏻) in the title bar that gracefully stops the WebUI server process from the browser. - api/routes.py: POST /api/shutdown endpoint with threaded os._exit(0) - static/boot.js: shutdownServer() with confirm prompt, BroadcastChannel cross-tab notification, and _showServerStopped() placeholder UI - static/index.html: shutdown button HTML in title bar (after reload btn) - static/style.css: .app-titlebar-shutdown styles, hover turns red --- api/routes.py | 16 ++++++++++++++++ static/boot.js | 22 ++++++++++++++++++++++ static/index.html | 6 ++++++ static/style.css | 2 ++ 4 files changed, 46 insertions(+) diff --git a/api/routes.py b/api/routes.py index 97db1f2e95..67683be8fb 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3628,6 +3628,20 @@ def _serve_shell_unavailable(handler, exc: Exception) -> bool: return True +def _handle_shutdown(handler) -> bool: + """Shut down the WebUI server process.""" + j(handler, {"status": "shutting_down"}) + import threading + + def _do_shutdown(): + import time + time.sleep(0.3) + os._exit(0) + + threading.Thread(target=_do_shutdown, daemon=True).start() + return True + + def _serve_manifest(handler) -> bool: """Serve static/manifest.json with the correct PWA Content-Type. @@ -4816,6 +4830,8 @@ def handle_post(handler, parsed) -> bool: finally: if diag: diag.finish() + if parsed.path == "/api/shutdown": + return _handle_shutdown(handler) # CSRF: reject cross-origin or tokenless authenticated browser requests. # /api/auth/login has no authenticated session token yet, and /api/csp-report # is intentionally unauthenticated for browser-generated violation reports. diff --git a/static/boot.js b/static/boot.js index e32d11d3b7..a16480e783 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1,3 +1,13 @@ +(function(){ + // Clear stale stop-server flag on successful page load (server is reachable) + localStorage.removeItem('hermes-webui-server-stopped'); + // Listen for shutdown broadcast from other tabs + try { + var _stopChan = new BroadcastChannel('hermes-webui-shutdown'); + _stopChan.onmessage = function() { _showServerStopped(); }; + } catch(_) {} +})(); + async function cancelStream(){ const streamId = S.activeStreamId; if(!streamId) return; @@ -1860,3 +1870,15 @@ window.addEventListener('pageshow', async (event) => { } catch (_) {} } }); + +async function shutdownServer() { + if (!confirm('Stop the Hermes WebUI server?')) return; + localStorage.setItem('hermes-webui-server-stopped', '1'); + try { var bc = new BroadcastChannel('hermes-webui-shutdown'); bc.postMessage('stop'); bc.close(); } catch(_) {} + _showServerStopped(); + try { await fetch('/api/shutdown', { method: 'POST' }); } catch (_) {} +} + +function _showServerStopped() { + document.body.innerHTML = '

Server stopped. You can close this tab.

'; +} diff --git a/static/index.html b/static/index.html index 722d38f78e..23e469b5ce 100644 --- a/static/index.html +++ b/static/index.html @@ -146,6 +146,12 @@ +
diff --git a/static/sessions.js b/static/sessions.js index 4d778d3a15..e14d19529e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2666,9 +2666,29 @@ function _sessionSearchContentPreview(session, query){ return preview||''; } +function syncSessionSearchClear(){ + const input=$('sessionSearch'); + const clear=$('sessionSearchClear'); + if(!input||!clear) return; + clear.hidden=!Boolean(input.value); +} + +function clearSessionSearch(focusInput=true){ + const input=$('sessionSearch'); + if(!input) return; + if(input.value){ + input.value=''; + filterSessions(); + }else{ + syncSessionSearchClear(); + } + if(focusInput) input.focus(); +} + function filterSessions(){ // Immediate client-side title filter (no flicker) // Debounced content search via API for message text + syncSessionSearchClear(); const q = ($('sessionSearch').value || '').trim(); if(q!==_lastSessionSearchQuery){ _lastSessionSearchQuery=q; diff --git a/static/style.css b/static/style.css index d932d8f3ed..95eb620da6 100644 --- a/static/style.css +++ b/static/style.css @@ -701,11 +701,18 @@ .new-chat-btn{width:100%;padding:9px 12px;border-radius:9px;background:var(--accent-bg);border:1px solid var(--accent-bg-strong);color:var(--accent-text);font-size:13px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:all .15s;margin-bottom:8px;font-weight:500;} .new-chat-btn:hover{background:var(--accent-bg-strong);border-color:var(--accent);} .session-list{flex:1;overflow-y:auto;padding:0 8px 8px;min-height:0;overscroll-behavior-y:contain;touch-action:pan-y;overflow-anchor:none;} - .sidebar-search{position:relative;padding:8px 12px;flex-shrink:0;} + .sidebar-search{padding:8px 12px;flex-shrink:0;} + .session-search-field{position:relative;display:flex;align-items:center;width:100%;} .sidebar-search input{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 10px 7px 32px;font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s;box-sizing:border-box;} + .session-search input{padding-right:34px;} .sidebar-search input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);} .sidebar-search input::placeholder{color:var(--muted);opacity:.7;} - .sidebar-search-icon{position:absolute;left:22px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--muted);opacity:.7;pointer-events:none;} + .sidebar-search-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);width:14px;height:14px;color:var(--muted);opacity:.7;pointer-events:none;} + .session-search .session-search-clear{position:absolute;right:6px;top:50%;transform:translateY(-50%);z-index:1;width:24px;height:24px;border:0;border-radius:6px;background:transparent;color:var(--muted);display:flex;align-items:center;justify-content:center;padding:0;cursor:pointer;opacity:.78;} + .session-search-clear[hidden]{display:none;} + .session-search-clear svg{width:14px;height:14px;} + .session-search-clear:hover,.session-search-clear:focus-visible{background:var(--hover);color:var(--text);opacity:1;} + .session-search-clear:focus-visible{outline:2px solid var(--accent);outline-offset:1px;} /* Inline session title edit */ .session-title-input{flex:1;background:var(--surface);border:1px solid var(--accent);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px var(--accent-bg-strong);font-family:inherit;} /* padding-right was 86px to reserve space for the absolute-positioned diff --git a/tests/test_sidebar_search_highlights.py b/tests/test_sidebar_search_highlights.py index 47cd44c122..c65838d84e 100644 --- a/tests/test_sidebar_search_highlights.py +++ b/tests/test_sidebar_search_highlights.py @@ -7,6 +7,7 @@ ROOT = Path(__file__).resolve().parent.parent SESSIONS_JS = ROOT / "static" / "sessions.js" STYLE_CSS = ROOT / "static" / "style.css" +INDEX_HTML = ROOT / "static" / "index.html" def _run_js_ranges(cases): @@ -85,3 +86,63 @@ def test_sidebar_search_rendering_uses_safe_dom_helpers(): assert "if(($('sessionSearch').value||'').trim()) _hideSearchPreviewsAfterSelect=true;" in src assert ".session-search-preview" in css assert "-webkit-line-clamp:2" in css + + +def test_session_search_has_accessible_clear_button(): + html = INDEX_HTML.read_text(encoding="utf-8") + assert '