feat(web): project folder context menu on right-click and long-press - #4060
feat(web): project folder context menu on right-click and long-press#4060btli wants to merge 30 commits into
Conversation
|
Pushed 2 commits:
Mutation-verified in both directions: never-arm breaks the touch tests, always-arm breaks the keyboard/mouse tests. Recommended merge order — this PR is one of seven fixed and verified together; the fully integrated reference (all cross-PR conflicts resolved, 5,003 web tests + 273/273 Android tests green) is Android track (suggest landing first — security fixes, and the later two resolve against it):
Web track (file-disjoint from the Android track; order within it matters): |
1c4372f to
af248e3
Compare
Add a configurable horizontal swipe gesture on left-panel session rows for touch devices. Each direction (left/right) maps to an action — archive, delete, or none — persisted per-device and set from Settings → Appearance. Swipe→archive reuses the kebab's stop-then-archive handler; swipe→delete opens the same confirm dialog the kebab uses, so delete is never immediate. The gesture locks by axis and yields to dnd-kit's long-press drag, leaving drag-to-file-into-project unaffected. The whole row — link, session-state badge, and pin/kebab controls — lives on one translating surface, so trailing icons travel with the text instead of the text sliding out from under them. Swipe-only markup and the opaque row background are gated on an in-progress swipe, so a row at rest renders exactly as it did before the gesture existed. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Follow-ups from review of the swipe gesture: - Commit off a synchronously-tracked offset instead of the rendered `dx`. A fast flick can lift the finger before React commits the render for the final pointermove, so the action was decided from a stale offset — a swipe past the threshold could fail to fire, and one that snapped back could fire anyway. - Claim the horizontal axis with `touch-pan-y` where a swipe can fire, so the browser can't take the pan (or a back-navigation gesture) and cancel the drag mid-swipe. Vertical scrolling stays native. - Ignore pointerdowns that bubbled out of a portal. The row's dialogs are React children rendered in portals, so a drag inside the open delete dialog would otherwise start a swipe on the row behind it. - Reset the swipe preference with "Reset appearance", which promises to clear every appearance choice. - Transition the row transform only at rest, so it tracks the finger 1:1 while swiping instead of easing behind it. - Scale the hint glyph past the commit point so the "will fire on release" state isn't carried by color alone. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
The archive direction looked unfinished next to delete: the hint used `bg-primary/15`, and --primary is near-black in light mode, so it rendered as a flat grey block that read as a disabled button. It now uses the accent pair, which is blue in both modes, and deepens its tint past the commit point. The row also inset-slides instead of translating. A translate pushed the title past the panel boundary, where it was cut mid-word: revealing the hint needs ~48px of gap but the title only has ~18px of slack, so clipping was inherent to translating. Insetting from the swiped edge lets the title re-truncate with its existing ellipsis and keeps every row control inside the panel. Travel past the commit point is also damped to a third and hard-capped, so a long drag resists rather than dragging the row further than the action needs. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Archiving stopped issuing a client-side stop some time ago — the server stops the runner once the flag commits, and a client stop would race it. The swipe test still asserted the old stop-then-archive sequence, so it failed against code that is behaving correctly. Assert the real contract instead, including that no client stop fires. Signed-off-by: Bryan Li <bryan.li@gmail.com>
Cover the Settings direction mapping end to end, reveal/action pairing, the exact 72px commit boundary, single dispatch, and a throwing localStorage getter. Unrecognized stored actions now normalize to none instead of silently arming archive. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Fold the settings-select drive into one helper, let a held swipe release itself instead of re-passing dx, and share the reveal icon assertions. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
An unfolded foldable or tablet is wider than the md breakpoint, so the width gate turned swipe-to-archive/delete off on a touch device. The gate now asks whether a coarse pointer exists (any-pointer: coarse); useRowSwipe still accepts only touch pointers, so mouse paths are untouched at any width. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
- Suppress the trailing click after any beyond-slop swipe so a committed swipe can never also navigate into the session (pointer-capture + preventDefault behavior varies across mobile browsers). Scoped to the row itself so portalled dialog/menu clicks pass through. - Mirror the row state in the swipe reveal: an archived row shows the unarchive glyph, since the gesture restores rather than re-archives. - Clamp the drag offset to rest when a locked swipe reverses into a direction mapped to none, instead of sliding the row over bare canvas. - Disarm the gesture (and drop the touch-pan-y override) when both directions are mapped to none, so the browser keeps horizontal gestures. - Extract a shared useMediaQuery store (one native change listener per query) and reimplement useCoarsePointer/useIsMobileViewport on it. - Cache the swipe-preference snapshot; refresh it on the change events instead of re-reading and re-parsing localStorage on every row render. - Drop the swipeActionRef indirection: useRowSwipe already tracks onAction in its handler deps, so the callback is passed directly. Tests cover the new behaviors: trailing-click suppression (asserted via the router location, not test-side preventDefault), the archived-row hint, the none-direction clamp, and the touch-action gating. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
One module-level listener pair feeds all swipe-preference subscribers (the useMediaQuery shape) instead of two window listeners per row, and actionFor() centralizes the offset-direction -> configured-action map that was spelled out in four places. Collapse a duplicated damping-test step to a single hard-cap assertion. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
- Reset the shared context-menu open state when the row's render branch changes, so a remounted ContextMenu can't reopen anchored at (0,0) - Drop the isDraggingRef mirror; a [isDragging]-dep callback is enough - DRY the e2e touch tests with a touch_page fixture plus _touch/_center helpers, and delete the project created by the drop test on teardown Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
A session row had three touch consumers arming on independent thresholds with no arbiter: dnd-kit's drag sensor (250ms hold), Radix's context menu (700ms long-press), and the horizontal row swipe (12px). dnd-kit won every stationary hold, so Radix's later open request landed during a live drag and was rejected — long-press could not reach the menu at all — and a slow swipe that stayed inside the sensor's tolerance became a drag instead. Replace the racing timers with `useRowGesture`, which owns the gesture and dispatches one intent: horizontal travel swipes, other travel scrolls, and a still hold arms a pick-up that either drags on movement or opens the menu on release. `RowGestureTouchSensor` binds its activator to touchmove and consults the recognizer, so dnd-kit is registered without an activation constraint and arms no timer of its own — there is nothing left to race. Radix's trigger keeps its own 700ms timer, which would open a second menu mid-hold, so touch pointerdown is defaultPrevented on the row link; pen still reaches Radix, which the recognizer does not claim. The hold also honours a drift tolerance, restoring what the removed sensor constraint provided: a finger still creeping is scrolling slowly, not holding still. Declare VIBRATE so the pick-up haptic is audible to the WebView. Signed-off-by: Bryan Li <bryan.li@gmail.com> Co-authored-by: Isaac
af248e3 to
e6c5e8e
Compare
|
This PR is part of a stacked train rebased onto current main so the whole set merges conflict-free in order. Full order: #3985 → #4065 → #4060 → #4057 (web/Sidebar train) and #3587 → #3589 → #3800 (android train). The two trains are independent of each other. This PR is 3rd in the web train, stacked on #4065. Until its predecessors merge, this PR's diff temporarily includes their commits; it collapses to a clean, self-contained diff automatically as each predecessor lands. |
Cancel the recognizer (and dnd-kit's drag) on any second touch anywhere, block the native context menu while a row drag is live, clear suppressClick on every fresh press, and follow dnd-kit's own drag end/cancel so a viewport resize can't leave the recognizer armed. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Name the armed-or-drag touch-action check, drop the `state.current` guards that reset() now makes redundant, and reuse a second-touch pointer constant in the gesture tests. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
…uch capability A real fingertip wobbles well past 12px during a 400ms hold, so the pending phase demoted to scroll before the drag could arm. The explicit scroll fallback widens to a 25px circle (native pan-y still wins earlier) and the hold tolerance to 20px; swipe keeps its 12px horizontal-dominant lock, and 25/sqrt(2) > 12 keeps the two regions disjoint. Gestures now enable wherever a coarse pointer exists instead of below the md breakpoint, so an unfolded foldable keeps swipe and long-press drag. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
The context menu now appears the moment the 400ms hold arms (with the haptic and lift) instead of on release. Pulling 10px from the arm point dismisses the menu once (Escape through Radix's layer) and hands the row to dnd-kit; anything less keeps the menu, and release leaves it open through the trailing click. The threshold measures from the arm point so legal pre-arm drift can't pre-spend the drag budget. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
…ess gesture Three leaks on real touch devices: Chrome samples touch-action at touchstart, so nothing stopped it claiming the armed gesture's first vertical moves as a pan (pointercancel, no drag) — a native non-passive touchmove guard now runs from arm until reset. The row menus rendered modally, whose body pointer-events lock let touches fall through the sidebar overlay and start text selection on the chat beneath — they are non-modal now. And the menu surface itself carried no user-select or touch-action, so drags starting on it seeded selection behind it. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
…owns the touch At ~500ms Android fires its own contextmenu on the still-held touch — 100ms after the recognizer opened the menu. Pointer capture retargets it to the row root, bypassing Radix's trigger, and unprevented it starts text selection and cancels the pointer stream, which killed the pending drag. The recognizer's own dispatch is tagged and passes through; everything else is prevented while the gesture is armed or dragging. Root-caused with real Chrome touch dispatch over CDP: with touch-action pan-y, pointercancel fires through pointer capture unless the pan and the OS menu are both forestalled. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
…re owns the touch The OS long-press hit-tests the element under the finger — once the row menu opens at the press point, that is the menu portal, past the row-level guard, so Android still engaged selection and cancelled the pointer stream (drag worked only when the pull beat the ~500ms long-press). The contextmenu/selectstart suppression now rides the armed-gesture guard at the document, capture phase; the recognizer's tagged dispatch passes through. Root-caused live on the device: a CDP-synthesized gesture (which bypasses the OS long-press) completed the full menu->dismiss->drag->drop pipeline, isolating the failure to the View-layer path only real fingers take. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
…ess survives Chrome Android hands a stationary long-press on a draggable link to its native drag machinery, firing an unconditional pointercancel before the contextmenu event — no preventDefault can save the pointer stream. With draggable=false the long-press has no native claimant left and the row gesture's menu-then-drag works from a stationary hold. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
…synthetic Escape A document-visible Escape keydown is exactly what dnd-kit's pointer sensors listen to for drag cancellation — dismissing the menu that way races the drag it is meant to make room for. Controlled open state closes the menu directly with no key event. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Project folder headers only exposed their actions through a hover-revealed kebab. A right-click fell through to the browser's native menu, and touch — which has no right-click at all — could not reach the actions in any way. Session rows already had both gestures, so the two row types behaved inconsistently. Attach a Radix ContextMenuTrigger to the folder header button, which brings both gestures with it: contextmenu for mouse, and a built-in 700ms pointerdown timer for touch. No custom gesture code, and no dnd-kit sensor changes (the folder is useDroppable, which contributes a ref only). The kebab is unchanged — this is additive parity. The menu body is authored once (ProjectFolderMenuItems) and rendered under both primitive families via the existing dropdown/context MenuComponents bundles, mirroring how ConversationMenuItems does it for session rows. The rename / settings / delete dialogs move up into a useProjectFolderMenu hook so the kebab and the context menu share one set rather than each mounting its own. The trigger wraps the header BUTTON only, never the outer folder div or the section — either would enclose the nested session rows and hijack right-click on them. Opening the menu must also not toggle the folder: the header's onClick flips expand/collapse, and a long-press's trailing click would otherwise collapse the folder under the just-opened menu, so that one click is swallowed while a plain left-click still toggles. Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
I, Bryan Li <bryan.li@gmail.com>, hereby add my Signed-off-by to this commit: afc9732 Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Gate the pending-long-press flag on the same non-mouse pointer test Radix uses to arm its timer, and share one Escape helper in the tests. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
A long-press on a folder header must open the context menu, not select the title. preventDefault on touch pointerdown (the session-row mechanism) would cancel Radix's composed long-press timer, so the header uses user-select: none instead. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
Clear the header's click-swallow when the context menu closes, so an opening gesture with no trailing click (pen barrel right-click, touch release over the portaled menu) can't strand the flag and eat the next keyboard Enter; add a regression test. Also hoist the thrice-duplicated menu-content class to a module const, fold contextMenuDisabled into a nullable contextMenu prop, drop a useMemo with no memoized consumers, and fix a test comment that misdescribed Radix's asChild trigger DOM. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
…evices Desktop hides these menu items behind hover-revealed buttons, but a coarse-pointer device has no hover at any width — the width-only gate left wide touch devices with no way to pin a session or start one in a project. Also corrects the swipe/scroll boundary comment: the regions overlap and check order gives swipe precedence. Co-authored-by: Isaac Signed-off-by: Bryan Li <bryan.li@gmail.com>
e6c5e8e to
e69cf43
Compare
Related issue
Relates to the report in #2122 (already closed). Prior attempts #2130 and #2168
predate the sidebar refactor and were closed — one pre-empted by a planned
redesign, one auto-closed as a duplicate. Their rename work has since
shipped, so this PR is only the context-menu / long-press parity that was
never delivered.
N/A (no open issue to close).
Summary
A UX inconsistency: project folder headers had no context menu at all.
Right-clicking one fell through to the browser's native menu, and touch — which
cannot right-click — had no way whatsoever to reach the folder's actions.
Session rows already supported both gestures, so the two row types behaved
differently for no reason a user could see.
ContextMenuTriggerto the folder header button, whichbrings both gestures with it:
contextmenufor mouse, plus a built-in 700mspointerdown timer for touch. No custom gesture code, and no dnd-kit sensor
changes — the folder is
useDroppable, which contributes a ref only.ProjectFolderMenuItems) and renderedunder both primitive families via the existing
dropdownBundle/contextBundleMenuComponentspattern — the same approachConversationMenuItemsuses for session rows — so the two menus cannotdiverge. The rename / settings / delete dialogs move into a
useProjectFolderMenuhook so both entry points drive one shared set.Two traps the implementation had to thread:
<button>only — never the outerProjectFolderdiv or the<section>. Either of those contains the nestedsession rows, so wrapping them would hijack right-click on every child row.
A structural test asserts the trigger contains the header and not a row.
onClickflips expand/collapse. Along-press opens the menu mid-gesture off Radix's timer, but the trailing
pointerupstill produces a click — which would collapse the folder underthe just-opened menu. That one click is swallowed (armed on menu open,
re-cleared on the next pointerdown), so a plain left-click still toggles
exactly as before.
Test Plan
Unit (Vitest 4.1.10 / jsdom, Node 22):
E2E (Python Playwright 1.60.0):
pre-commit run --all-files→ all hooks pass.Detectors were validated, not assumed. Each guard was confirmed to fail when
the behaviour it protects is removed:
long-press on the project folder header did not open the context menu,while its positive control (a session row) still passes — proving the CDP
touch pipeline reached the page and the negative is real, not plumbing failure.
The e2e long-press uses CDP
Input.dispatchTouchEvent(touchStart → ~900mshold → touchEnd) on a
has_touch=True,is_mobile=True390x844 context, andpeak-polls during the hold.
page.touchscreen.tap()cannot hold a press, anda synthetic
dispatch_event("pointerdown", {pointerType: "touch"})emits notouchstart— that path is a known false pass and is deliberately avoided.Demo
Desktop — right-click on the folder header (note the kebab
⋯is still there):Mobile (390x844) — touch long-press on the folder header. This gesture had no
equivalent before; the actions were simply unreachable on touch. The menu here
also carries the mobile-only "New session" item, matching the kebab:
Type of change
Test coverage
Coverage notes
14 unit tests in
web/src/shell/Sidebar.projectContextMenu.test.tsx: kebabparity (asserted item-for-item, not against a hand-written list, so a future
kebab item that skips the shared body fails), each action reachable from the
context menu and landing in the same dialog/mutation, the mobile-only
"New session" href, left-click still toggling, neither gesture toggling, the
trigger's structural scope, a nested session row keeping its own menu, and
selection-mode suppression.
3 e2e tests in
tests/e2e_ui/sessions/test_sidebar_project_context_menu.py:desktop right-click, left-click toggle, and the CDP touch long-press.
Manual verification: drove a local server in a real browser at both desktop and
390x844 widths — right-click and long-press each open the menu, Rename/Settings/
Delete behave identically from either entry point, the kebab is unchanged, and
left-click still expands/collapses. The screenshots above are from that pass.
Changelog
Right-click or long-press a project folder in the sidebar to open its actions