Skip to content

Keep mobile ungroup drop zone below sessions - #4057

Open
btli wants to merge 34 commits into
omnigent-ai:mainfrom
btli:polly/mobile-ungroup-dropzone
Open

Keep mobile ungroup drop zone below sessions#4057
btli wants to merge 34 commits into
omnigent-ai:mainfrom
btli:polly/mobile-ungroup-dropzone

Conversation

@btli

@btli btli commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Related issue

N/A — reported directly without a linked issue.

Summary

Starting a filed-session drag on mobile used to insert the remove-from-project target above the list, shifting the held row under the user's finger. The target is now a plain last child after the project/session sections and renders for every filed-session drag, so mounting it never displaces rows.

pointerWithin remains the collision algorithm. A small wrapper only hoists __ungroup__ when the pointer is already inside that visible strip, preventing an overlapping project folder from silently re-filing the session. The sidebar's original single nav scroller and default dnd-kit measurement are unchanged, so desktop folders remain droppable at every scroll position.

The final Sidebar.tsx diff is 41 lines (25 additions, 16 deletions) versus main; the previous nested-scroll re-indentation and measurement override are fully removed.

ELI5: the remove area is added after the list, and when the finger is visibly on it, it wins over anything geometrically underneath.

[ Projects ][ Sessions ][ Drop to remove ]
                              ^
                 __ungroup__ wins only here

Test Plan

  • PATH=/tmp/node20/bin:$PATH pnpm vitest run src/shell/Sidebar.test.tsx src/shell/sidebarNav.test.ts from web/ — 2 files passed; 111/111 tests passed.
  • PATH=/tmp/node20/bin:$PATH pnpm vitest run from web/ — 274 files passed, 1 skipped; 4,891 passed, 3 expected failures, 1 skipped (4,895 collected).
  • PATH=/tmp/node20/bin:$PATH pnpm type-check from web/ — passed (tsc -b).
  • PATH=/tmp/node20/bin:$PATH pre-commit run --all-files from the repository root — all hooks passed.
  • Counterfactual without collision precedence: the focused overlap test failed with one mutation to { id: "conv_filed", project: "Sprint 42" } instead of project: "" (1 failed, 64 skipped). With precedence enabled it passes (1 passed, 65 skipped).
  • A pure collision-ordering test verifies __ungroup__ is hoisted when present and unrelated collision arrays retain their original order.

Demo

Scrolled mobile drag with the plain ungroup strip after the project list

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

The 390×844 demo shows the list scrolled to its natural end with the plain strip after all project rows and the drag overlay above it. The removed sticky/background classes were inactive in this layout, so the existing capture remains representative. The regression fixture overlaps a collapsed project at y=345–385 with the strip at y=350–390 and releases at y=360; only the ungroup mutation fires.

Changelog

Keep the mobile “Drop to remove from project” target below the session list and prevent project folders from stealing the drop.

@btli

btli commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 2 commits:

  • The ungroup strip only mounts on mobile — desktop already has ChatsDropZone as the ungroup target, so the collision hoist written for a sticky-strip layout that no longer exists is deleted outright.
  • Droppables re-measure when a section resize moves later zones without resizing them (resizeObserverConfig).
  • The matchMedia/ResizeObserver test stubs are now per-test controllable.

Both key behaviors are mutation-verified; full web suite green.


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 btli:test-android-ui-integration @ 3fa45ff3.

Android track (suggest landing first — security fixes, and the later two resolve against it):

  1. fix(android): sign in to servers behind front-door auth proxies (Databricks Apps) #3800 — no dependencies
  2. fix(web): keep the Workspace rail clear of the OS status and nav bars #3587 — rebase after fix(android): sign in to servers behind front-door auth proxies (Databricks Apps) #3800: it picks up applySystemBarContrast(Configuration), which fixes this branch's one pre-existing Robolectric failure; and since main now has flush rails (feat(web): make the rails flush boxes and move the canvas gradient #4020), the rebase should also drop the stale +16 from --workspace-panel-offset (see integration commit 8b687d7c)
  3. fix(android): keep the server switcher centred over the chat column, clear of header controls #3589 — after fix(android): sign in to servers behind front-door auth proxies (Databricks Apps) #3800: both add constructor callbacks to OmnigentBridgeListener; union them (resolution in integration merge 3fa45ff3)

Web track (file-disjoint from the Android track; order within it matters):
4. #3985 — base swipe-actions feature
5. #4060 — folder context menu
6. #4057 — mobile ungroup drop zone
7. #4065 — hold: superseded by the unified row-gesture recognizer, which builds on #3985 and interacts with #4057/#4060; after 4–6 land, update it to the recognizer commits (69a8ada5, bf597b87, e388aacf, reachable on the integration branch)

@btli

btli commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 43f46c826: the ungroup target now docks in flow at the seam between the project folders and the unfiled sessions — dropping a filed session lands it right where the strip sits. While the seam is scrolled out of the list viewport, a fixed strip floats at the nearer edge (top when the seam is above the view, bottom when below) and stands down as soon as scrolling brings the seam back in. Droppable rects re-measure on registry changes (MeasuringStrategy.Always) so the mid-drag mount keeps every target aligned with the shifted layout.

Covered by six focused tests: seam placement/order, in-flow slot with no fallback while visible, bottom-edge float, top-edge float, scroll latching, and an ungroup drop resolved through the floating strip.

@btli
btli force-pushed the polly/mobile-ungroup-dropzone branch from 736abc8 to 2b430ac Compare August 6, 2026 13:00
@btli
btli marked this pull request as ready for review August 6, 2026 13:10
@github-actions
github-actions Bot requested review from serena-ruan and removed request for daniellok-db August 6, 2026 13:10
@github-actions github-actions Bot assigned serena-ruan and unassigned daniellok-db Aug 6, 2026
btli added 5 commits August 6, 2026 10:23
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>
btli added 6 commits August 6, 2026 10:24
- 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
@btli
btli force-pushed the polly/mobile-ungroup-dropzone branch from 2b430ac to d5f4f24 Compare August 6, 2026 18:45
@btli

btli commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

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 4th in the web train, stacked on #4060. 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.

btli added 21 commits August 6, 2026 12:04
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>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Signed-off-by: Bryan Li <bryan.li@gmail.com>
…zones

Desktop already has ChatsDropZone as the ungroup target, so the strip
only mounts on mobile now. Drop the collision hoist written for a
sticky-strip layout that no longer exists, and re-measure droppables
when a section resize moves later zones without resizing them. Test
stubs for matchMedia and ResizeObserver are now per-test controllable.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
Share the matchMedia listener type and viewport fan-out, register the
resize-observer mock once, and pull the repeated pinned-filed-session
setup in the ungroup drag tests into one helper.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
@btli
btli force-pushed the polly/mobile-ungroup-dropzone branch from d5f4f24 to 2e02376 Compare August 6, 2026 19:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants