diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ba150de..66a2d4d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## [Unreleased] +## [v0.51.310] — 2026-06-07 — Release JZ (stage-3760 — long-press project chips to delete on touch) + +### Fixed +- **Project chips can now be managed on touch devices.** The project filter chips in the sidebar exposed Rename / Color / Delete only through the right-click context menu, which has no touch equivalent — so on phones/tablets there was no way to delete a project and the list grew forever. A 500ms long-press now opens the same context menu (mirroring the existing session-item long-press): a >10px drag cancels it, a short tap still filters, and the synthetic click after the press is suppressed. The chip shows accent + slight-scale feedback while held, and native callout/selection is disabled so it doesn't compete with the gesture. Includes a multi-touch correctness fix (a second finger / stray touchstart can no longer orphan the press timer and pop the menu after the gesture was cancelled). Verified live: long-press opens the menu once, short tap doesn't, and the multi-touch-then-cancel case no longer fires. (#3760, @reinocheong) + ## [v0.51.309] — 2026-06-07 — Release JY (stage-a5b — restore reconnect replays live tool cards) ### Fixed diff --git a/static/sessions.js b/static/sessions.js index be93145a..3c6c0c08 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -4694,6 +4694,50 @@ function renderSessionListFromCache(){ }; chip.ondblclick=(e)=>{e.stopPropagation();clearTimeout(_pClickTimer);_pClickTimer=null;_startProjectRename(p,chip);}; chip.oncontextmenu=(e)=>{e.preventDefault();_showProjectContextMenu(e,p,chip);}; + // Touch long-press → context menu (mobile UX: project chips can only be + // deleted via the right-click menu, which has no touch equivalent). + let _lpTimer=null; + let _lpHandled=false; + let _lpStartX=0,_lpStartY=0; + chip.addEventListener('touchstart',(e)=>{ + const t=e.changedTouches&&e.changedTouches[0]; + if(!t) return; + // Clear any in-flight timer before scheduling a new one, mirroring the + // session-item long-press path (_clearLongPressTimer). Without this a + // second finger / stray touchstart orphans the prior timer, which then + // fires unsuppressed ~500ms later and pops the menu after the gesture + // was cancelled. + if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;} + _lpHandled=false;_lpStartX=t.clientX;_lpStartY=t.clientY; + chip.classList.add('long-pressing'); + _lpTimer=setTimeout(()=>{ + _lpTimer=null; + if(_lpHandled) return; // already consumed by another gesture — stale fire is a no-op + _lpHandled=true; + chip.classList.remove('long-pressing'); + clearTimeout(_pClickTimer);_pClickTimer=null; + const syn={clientX:t.clientX,clientY:t.clientY,preventDefault:()=>{}}; + _showProjectContextMenu(syn,p,chip); + },500); + },{passive:true}); + chip.addEventListener('touchmove',(e)=>{ + if(!_lpTimer) return; + const t=e.changedTouches&&e.changedTouches[0]; + if(!t) return; + if(Math.abs(t.clientX-_lpStartX)>10||Math.abs(t.clientY-_lpStartY)>10){ + clearTimeout(_lpTimer);_lpTimer=null; + chip.classList.remove('long-pressing'); + } + },{passive:true}); + chip.addEventListener('touchend',(e)=>{ + clearTimeout(_lpTimer);_lpTimer=null; + chip.classList.remove('long-pressing'); + if(_lpHandled){e.preventDefault();e.stopPropagation();} + },{passive:false}); + chip.addEventListener('touchcancel',()=>{ + clearTimeout(_lpTimer);_lpTimer=null;_lpHandled=false; + chip.classList.remove('long-pressing'); + },{passive:true}); bar.appendChild(chip); } // Create button diff --git a/static/style.css b/static/style.css index 91c32414..6245d86e 100644 --- a/static/style.css +++ b/static/style.css @@ -4086,9 +4086,10 @@ main.main > #mainPlugin{display:none;} .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{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;-webkit-user-select:none;user-select:none;-webkit-touch-callout:none;touch-action:manipulation;} .project-chip:hover{background:rgba(255,255,255,.08);color:var(--text);} .project-chip.active{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg);} +.project-chip.long-pressing{background:var(--accent-bg);color:var(--accent-text);border-color:var(--accent-bg-strong);transform:scale(1.05);} /* "Unassigned" filter chip — dashed border distinguishes it from real project chips so it reads as a meta-filter ("things without a project") rather than another project. Keeps full color treatment in the active diff --git a/tests/test_project_chip_ui.py b/tests/test_project_chip_ui.py index 5a08b672..f1cab42c 100644 --- a/tests/test_project_chip_ui.py +++ b/tests/test_project_chip_ui.py @@ -166,3 +166,66 @@ class TestResizeProjectInputHelper: assert "addEventListener('input'" in body, ( "_startProjectCreate must wire input events to _resizeProjectInput" ) + + +class TestProjectChipLongPressTouch: + """Mobile long-press to open the project context menu (#3760). + + Project chips were deletable only via the right-click context menu, which has + no touch equivalent — so mobile users could never remove a project. A 500ms + long-press now opens the same menu. + """ + + def _chip_touch_block(self): + # The chip touch handlers live just after the oncontextmenu wiring in the + # project-chip render loop. + idx = SESSIONS_JS.find("Touch long-press") + assert idx != -1, "project-chip long-press touch block not found" + return SESSIONS_JS[idx: idx + 2300] + + def test_long_press_opens_project_context_menu(self): + block = self._chip_touch_block() + assert "addEventListener('touchstart'" in block + assert "setTimeout(" in block and "},500);" in block + assert "_showProjectContextMenu(" in block + # visual feedback + scroll-drift cancel, mirroring the session-item pattern + assert "long-pressing" in block + assert "addEventListener('touchmove'" in block + assert ">10" in block # >10px drift cancels the press + + def test_long_press_suppresses_synthetic_click_and_filter_tap(self): + block = self._chip_touch_block() + # touchend must be non-passive so it can preventDefault the synthetic click + assert "addEventListener('touchend'" in block + assert "{passive:false}" in block + assert "e.preventDefault();e.stopPropagation();" in block + # the long-press handler cancels the pending single-tap filter timer + assert "clearTimeout(_pClickTimer)" in block + + def test_touchstart_clears_inflight_timer_before_scheduling(self): + """Regression: a second finger / stray touchstart must not orphan the + prior timer (which would then fire the menu after the gesture was + cancelled). touchstart clears any in-flight _lpTimer before scheduling, + and the timer body bails if the gesture was already consumed. + """ + block = self._chip_touch_block() + # clear-before-schedule at the top of touchstart + assert "if(_lpTimer){clearTimeout(_lpTimer);_lpTimer=null;}" in block, ( + "touchstart must clear any in-flight long-press timer before scheduling " + "a new one (orphaned-timer fix)" + ) + # stale-fire guard inside the timer body + assert "if(_lpHandled) return;" in block, ( + "the long-press timer body must no-op if the gesture was already consumed" + ) + + def test_long_pressing_style_feedback_present(self): + assert ".project-chip.long-pressing" in STYLE_CSS + # Target the base .project-chip rule (the one carrying the layout props), + # not an unrelated theme override of the same selector. + base_idx = STYLE_CSS.find(".project-chip{font-size") + assert base_idx != -1, "base .project-chip rule not found" + chip_rule = STYLE_CSS[base_idx: STYLE_CSS.find("}", base_idx) + 1] + # touch tuning so the native callout/selection doesn't compete with the gesture + assert "touch-action:manipulation" in chip_rule + assert "user-select:none" in chip_rule