diff --git a/docs/pr-media/sidebar-hover-drag/after-hover-qa.png b/docs/pr-media/sidebar-hover-drag/after-hover-qa.png new file mode 100644 index 00000000..7142a1ab Binary files /dev/null and b/docs/pr-media/sidebar-hover-drag/after-hover-qa.png differ diff --git a/static/sessions.js b/static/sessions.js index f40290d5..849ee30c 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2509,15 +2509,31 @@ function renderSessionListFromCache(){ let _tapTimer=null; let _pointerDownX=0; let _pointerDownY=0; + let _pointerActive=false; let _isDragging=false; let _clearDragTimer=null; + const _clearPointerDragState=()=>{ + _pointerActive=false; + if(_isDragging){ + _isDragging=false; + if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} + _clearDragTimer=setTimeout(()=>{el.classList.remove('dragging');_clearDragTimer=null;},50); + } + }; 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'); }; 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); @@ -2528,8 +2544,11 @@ function renderSessionListFromCache(){ if(_clearDragTimer){clearTimeout(_clearDragTimer);_clearDragTimer=null;} } }; + 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; if(_renamingSid) return; if(actions&&actions.contains(e.target)) return; if(e.target&&e.target.closest&&e.target.closest('.session-child-count,.session-child-sessions,.session-child-session')) return; diff --git a/tests/test_issue500_session_list_virtualization.py b/tests/test_issue500_session_list_virtualization.py index d9229af1..911a7186 100644 --- a/tests/test_issue500_session_list_virtualization.py +++ b/tests/test_issue500_session_list_virtualization.py @@ -2,6 +2,7 @@ import json import shutil import subprocess +import tempfile from pathlib import Path import pytest @@ -14,13 +15,21 @@ pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") def _run_node(source: str) -> str: - result = subprocess.run( - [NODE, "-e", source], - cwd=str(REPO_ROOT), - capture_output=True, - text=True, - timeout=10, - ) + with tempfile.NamedTemporaryFile( + "w", suffix=".cjs", encoding="utf-8", dir=REPO_ROOT, delete=False + ) as script: + script.write(source) + script_path = Path(script.name) + try: + result = subprocess.run( + [NODE, str(script_path)], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=10, + ) + finally: + script_path.unlink(missing_ok=True) if result.returncode != 0: raise RuntimeError(result.stderr) return result.stdout.strip() diff --git a/tests/test_issue856_pinned_indicator_layout.py b/tests/test_issue856_pinned_indicator_layout.py index f90cc7b0..31a30778 100644 --- a/tests/test_issue856_pinned_indicator_layout.py +++ b/tests/test_issue856_pinned_indicator_layout.py @@ -116,6 +116,15 @@ def test_timestamp_hidden_when_attention_state_is_present(): assert ".session-item.unread:not(:hover):not(:focus-within):not(.menu-open) .session-actions" in STYLE_CSS +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 ".session-item.dragging:hover" in STYLE_CSS + + def test_sidebar_uses_local_inflight_state_for_immediate_spinner(): messages_js = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()