Skip to content

feat(web): swipe session rows to archive or delete on touch - #3985

Open
btli wants to merge 11 commits into
omnigent-ai:mainfrom
btli:feature/mobile-slide-actions-v2
Open

feat(web): swipe session rows to archive or delete on touch#3985
btli wants to merge 11 commits into
omnigent-ai:mainfrom
btli:feature/mobile-slide-actions-v2

Conversation

@btli

@btli btli commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Related issue

N/A

Supersedes #3154 (auto-closed after 7 days on waiting-on-author; I don't
have permission to reopen it, hence a fresh PR). This carries the same feature
plus fixes for every point @fanzeyi raised in that review, rebased onto current
main. See Review feedback from #3154 below for the point-by-point.

Summary

Session rows in the left panel had no touch-friendly quick actions — on mobile
you had to open the kebab to archive or delete. This adds a configurable
horizontal swipe gesture on each row:

  • Swipe a row left or right to run an action. Each direction independently maps
    to Archive, Delete, or None, persisted per-device.
  • Configure it in Settings → Appearance → Swipe actions (two selects, one per
    direction). Defaults: swipe-left → Archive, swipe-right → None.
  • Swipe→archive reuses the kebab's existing stop-then-archive handler;
    swipe→delete opens the same delete confirm dialog the kebab uses (no
    immediate delete, no separate undo path). "None" is inert.
  • Coexists with the existing dnd-kit drag-to-project: the swipe locks by axis and
    yields to the long-press drag, so drag-to-file-into-project is unaffected.
    Gated to touch/mobile so desktop hover controls don't regress.

The whole row — link, session-state badge, and pin/kebab controls — moves as one
surface, so trailing icons travel with the text rather than the text sliding out
from under them. The row insets from the swiped edge rather than translating:
revealing the hint needs ~48px of gap but the title only has ~18px of slack
before the panel boundary, so a translate would cut the title mid-word. An inset
lets the title re-truncate with its existing ellipsis and keeps every control
inside the panel. All swipe-only markup and styling is gated on an in-progress
swipe, so a row at rest renders exactly as it did before this change.

Review feedback from #3154

All three points, verified in a real browser at a 414×800 touch viewport (numbers
are getBoundingClientRect / getComputedStyle readings from the running app):

Feedback Fix Measured
"There are now background color for each row in the session" The row wrapper had an unconditional bg-sidebar, plating every row at rest — desktop included. It now paints only mid-swipe. At rest backgroundColor: rgba(0, 0, 0, 0); mid-swipe rgb(253, 250, 250)
"The icons location are incorrect in small screen" (unread indicator) The badge and pin/kebab are absolutely positioned; they sat outside the moving wrapper. All row content now shares one layout-neutral surface. Wrapper rect identical to the <li> at rest: left 9, right 400.5, top 292.5, height 36
"When swiping, the icon is now overlapping with the session content itself" Because the icons didn't move with the row, text slid under them. Row content now moves as one surface and the reveal hint occupies the space the row vacates, so the two never share pixels. Swiping left, the row's right edge insets 400.5 → 325.8 and the kebab travels with it (369 → 294.3); the hint sits in the vacated strip

Additional hardening

A second review pass (Codex, gpt-5.6-sol) found issues beyond the original
feedback, fixed in the follow-up commits:

  • Commit off a synchronously-tracked offset, not 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 under it could fire
    anyway. Covered by a regression test that fails on the old code.
  • touch-pan-y where a swipe can fire, so the browser can't claim the
    horizontal 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 appearance" resets the swipe preference, which it promises to do for
    every appearance choice.
  • Transitions only at rest, so the row tracks the finger 1:1 while swiping
    instead of easing behind it.
  • The hint glyph scales and its tint deepens past the commit point, so "will fire
    on release" isn't signalled by color alone.
  • The archive reveal uses the accent pair rather than --primary, which is
    near-black in light mode and rendered the hint as a flat grey block that read
    as a disabled button instead of a revealed surface.
  • Travel past the commit point is damped to a third and hard-capped, so a long
    drag resists rather than pulling the row further than the action needs.

Test Plan

Run in web/ (Node 20 — Node 26 breaks jsdom's localStorage):

  • npm run type-check — clean
  • npm run lint — clean (exit 0)
  • npm run test4907 passed | 3 expected fail | 1 skipped
  • npm run format:check — all files match Prettier style

New/updated tests:

  • web/src/lib/swipeActionPreferences.test.ts — read/write/validate/defaults
    round-trip.
  • web/src/shell/Sidebar.rowActions.test.tsx — swipe→archive drives the
    stop→archive handler; swipe→delete opens the confirm dialog (deletes only after
    confirm); "none" is inert; both-directions-same-action; sub-threshold swipe is a
    no-op; desktop and non-touch pointers ignore the gesture; vertical drags yield
    to scroll; dnd-kit takes over mid-gesture; a fast flick that releases before
    the last move renders still commits
    , and one that snaps back under the
    threshold does not; portal-bubbled pointerdowns are ignored; travel tracks 1:1
    to the commit point then damps and caps, so the title can't be pulled out of
    the panel.

Manual verification drove the real app (spawned omnigent server + runner) with
CDP touch events — page.mouse sends pointerType: "mouse", which the gesture
ignores by design — and read back live geometry for the table above.

Demo

Recorded against the running app on a 414×800 touch viewport. The clip shows
Settings → Appearance → Swipe actions with swipe-left → Archive and
swipe-right → Delete, then back on the session list: a swipe-left archives a
row (it insets to reveal the archive hint, then leaves the list), and a
swipe-right on another row opens the Delete confirmation dialog — delete
stays gated behind confirm, never immediate.

Swipe a session row to archive or delete on touch

Download MP4

Stills — note the rows at rest carry no background plate, and the kebab travels
with the row mid-swipe:

Settings Rows at rest Mid-swipe (archive) Mid-swipe (delete) Delete confirm
settings at rest archive delete confirm

The row title stays fully inside the panel throughout — measured left: 18 at
rest and mid-swipe alike — and truncates with an ellipsis when the inset narrows
it, rather than being clipped by the panel edge.

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

Behavior is covered by unit tests: the preference round-trip, and the gesture
state machine driven with synthetic pointer events (including the fast-flick and
snap-back cases that jsdom's render timing would otherwise mask). The live touch
gesture, the at-rest/mid-swipe geometry, and the visual affordance were verified
against the running app in a real browser — see Test Plan and the Demo above.

Changelog

Swipe left or right on a session row (touch devices) to archive or delete it, configurable in Settings → Appearance.

This pull request and its description were written by Isaac.

@github-actions github-actions Bot added the size/XL Pull request size: XL label Aug 3, 2026
@github-actions
github-actions Bot requested a review from serena-ruan August 3, 2026 18:07
@btli
btli force-pushed the feature/mobile-slide-actions-v2 branch from 2fbbcfc to d2d7ee9 Compare August 3, 2026 18:11
btli added 3 commits August 4, 2026 02:10
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>
@btli
btli force-pushed the feature/mobile-slide-actions-v2 branch from d61f846 to a0fed78 Compare August 4, 2026 09:11
btli added 3 commits August 4, 2026 06:42
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>
@btli

btli commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 2 commits closing the review's coverage gaps:

  • The Settings direction mapping is covered end to end (a swapped [direction] write now fails a test), along with reveal/action pairing, the exact 72px commit boundary, single dispatch, and a throwing localStorage getter.
  • One small runtime change: unrecognized stored actions normalize to none instead of silently arming archive.

Each new test was mutation-verified against the specific defect it guards.


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 added 5 commits August 5, 2026 09:44
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>
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>
…acks

actionFor reads both directions, so the handlers key their memoization
on the preferences object itself.

Co-authored-by: Isaac
Signed-off-by: Bryan Li <bryan.li@gmail.com>
@btli
btli force-pushed the feature/mobile-slide-actions-v2 branch from 470da7b to 8c77527 Compare August 6, 2026 01:41
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.

2 participants