Release v0.51.310 — Release JZ (#3760 — long-press project chips to manage on touch) (#3767)

* feat(sidebar): long-press project chips to open the context menu on touch (#3760)

Project filter chips could only be deleted/renamed via the right-click context
menu (oncontextmenu), which has no touch equivalent — so mobile/tablet users had
no way to delete a project from the sidebar; the list grew forever.

Adds a 500ms long-press gesture mirroring the existing session-item long-press
pattern: touchstart schedules the menu, touchmove cancels on >10px drift,
touchend suppresses the synthetic click when the long-press fired, touchcancel
cleans up. `.project-chip.long-pressing` gives accent + slight-scale feedback;
`touch-action:manipulation` + `user-select:none` + `-webkit-touch-callout:none`
prevent the native callout/selection from competing.

Maintainer fix on top of the contributor PR (multi-touch correctness, flagged in
review): touchstart now clears any in-flight `_lpTimer` before scheduling a new
one (a second finger / stray touchstart previously orphaned the prior timer,
which then fired unsuppressed ~500ms later and popped the menu after the gesture
was cancelled), and the timer body bails if `_lpHandled` is already set so a
stale fire is a no-op — matching the session-item belt-and-suspenders. Also
dropped a stale, unrelated issue reference from the original comment.

Co-authored-by: reinocheong <[email protected]>

* docs(changelog): stamp v0.51.310 — Release JZ (stage-3760 long-press project chips)

---------

Co-authored-by: nesquena-hermes <[email protected]>
This commit is contained in:
nesquena-hermes 2026-06-06 21:36:24 -07:00 committed by GitHub
parent a20ef5e0c3
commit 4b390e115c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 114 additions and 1 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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