diff --git a/static/sessions.js b/static/sessions.js index fbde5c7b..27a56b9a 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 66cf0102..84bc88d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -74,6 +74,15 @@ os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR) # tests that read/write config.yaml stay inside the isolated test home. os.environ['HERMES_CONFIG_PATH'] = str(TEST_STATE_DIR / 'config.yaml') + +@pytest.fixture(autouse=True) +def _isolate_hermes_config_path(): + """Keep profile/.env side effects from leaking the live config path across tests.""" + isolated_config_path = str(TEST_STATE_DIR / 'config.yaml') + os.environ['HERMES_CONFIG_PATH'] = isolated_config_path + yield + os.environ['HERMES_CONFIG_PATH'] = isolated_config_path + # ── Server script: always relative to repo root ─────────────────────────── SERVER_SCRIPT = REPO_ROOT / 'server.py' if not SERVER_SCRIPT.exists(): diff --git a/tests/test_issue2454_active_session_spinner.py b/tests/test_issue2454_active_session_spinner.py index 22e7d59d..21e490c3 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 09785d40..b7c13056 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 f42f8353..6f896ef3 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