Skip to content

feat(chat): swipe both mobile panels, and settle by distance and speed - #7073

Merged
buluoray merged 1 commit into
mainfrom
feat/mobile-swipe-both-panels
Aug 30, 2026
Merged

feat(chat): swipe both mobile panels, and settle by distance and speed#7073
buluoray merged 1 commit into
mainfrom
feat/mobile-swipe-both-panels

Conversation

@buluoray

@buluoray buluoray commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

The mobile chat had one gesture, and it was hard to find. The sessions drawer armed only inside a 24–120px band at the left edge — the leftmost quarter of a 390px phone — so a rightward drag begun where a thumb naturally lands did nothing. The right-hand side panel had no gesture at all; it could only be opened from the header.

Once the gesture worked, the settle was the next problem: it was a fixed duration, so throwing a panel shut and nudging it shut played the identical animation, and a dismissal's curve accelerated away instead of gliding to a stop.

Why it matters

The drawer holds every other conversation and the side panel holds files, diffs and app tabs — on a phone these are the two most-reached-for surfaces, and both were behind a control rather than a gesture. Beyond reachability, a panel that ignores how hard it was thrown reads as unresponsive: the motion is the only feedback a touch surface gives back.

What changed (motivation → approach → change)

Finding the gesture. Direction, not start position, now selects the panel: a rightward drag opens the sessions drawer, a leftward one opens the side panel, from anywhere in the pane except the platform's own two edge bands (its back/forward swipes are not cancellable from script, so contending for those touches is lost by default). That is what lets one binding serve each panel and what makes the gesture findable. The one case direction cannot separate is a panel that is already OPEN — its closing drag is the other panel's opening drag — so each instance is disabled while the other's panel is on screen.

The settle became a function of the input. A release continues the finger: it decelerates from the speed the finger let go at, derived so the first painted frame matches that speed, which makes a harder flick land sooner. That speed is measured across a 100ms window rather than the final pair of samples — a flick whose lift landed more than one frame after the last touchmove used to have its entire speed discarded and settled in exactly the same time as a gentle drag.

A tap is the other case, not the same one. It carries no velocity, so it plays the slowest reading of a release, and it launches rather than jumping: the release curve opens at 2.25× its average speed, which is continuity behind a moving finger but a 31.6px jump from a standstill. The tap dismissal keeps that curve's hard tail and softens only its launch (15.3px). Softening the whole curve was tried and reverted — it cut the glide into the edge from 285ms to 208ms and read as merely stopping.

Durations are held as a speed, not a time. A duration tuned on a panel crossing most of the screen makes a narrow one crawl: the nav drawer travels 231px against the sessions drawer's 350px, so one shared number moved it 1.46× slower per pixel. Each settle now scales by the distance it actually has to cover, bounded above by the reference duration and below by the same floor a release uses.

Two panels travelled further than they are wide, which spent the deceleration moving something already off screen — the sessions drawer was fully hidden at 90% of its slide (140ms of a dismissal animating nothing, its scrim still 10% dark and fading alone), the nav drawer at 96%. Their travel now derives from their own width, so the panel's edge and the scrim's zero land on the same frame. For the sessions drawer that one number is also the drag's clamp, its commit threshold and the release-speed denominator, so all four agree. Both travels also add env(safe-area-inset-left), which the first revision of this PR missed: the panels are pinned at left-safe, so on a notched phone in landscape they start an inset in and parked with an inset-wide strip still visible — the same defect as undershooting the width, reachable only in that orientation. Raised by the GPT 5.6 review as a non-blocking finding, verified against the CSS the panels actually use, and fixed by resolving env() through a memoized probe rather than guessing a number.

The commit threshold is measured from where the gesture started rather than from the panel's absolute position. Read absolutely, a light threshold for opening becomes an 80%-of-screen threshold for closing; measured as displacement, one fifth of the travel means the same light ask in both directions.

Tests

Two new files and four extended, 101 cases over the drawer surface:

  • drawerSwipeTwoPanels.test.ts — the composition ChatPage renders: two instances on one element. Direction separates them while both are closed; the enabled cross-gate is what stops a dismissal from opening the opposite panel.
  • drawerGestureSettle.test.ts — the release timing. Deceleration in both directions, duration falling as the throw hardens, the continuity condition (distance × slope ÷ speed) inside the band, both bounds, a flick pointing away from the target ignored, a hold-then-lift timing exactly like a tap, distance-scaled tap durations at equal speed, the reference cap, and the floor. Plus the end-to-end wiring: a flicked close lands sooner than a dragged one, and a flick whose lift lands a frame late keeps its speed.
  • useDrawerSwipe.test.ts — full-pane arming, both platform edge bands refused, the right-anchored mirror, and the commit share measured against the panel rather than the screen, in both directions.
  • animateDrawer.curve.test.ts — the two curves and why they differ; the exit's first-frame ceiling (the anti-jump guard) and its glide floor.
  • mobilePanels.compositor.test.ts — source contracts: one travel reaching all five consumers, the nav drawer's travel derived from its width and inset, and the gesture bindings' cross-gate.
  • useWebSocket.streamHold.test.ts — one assertion relaxed: it asserted the flush hold exceeded 300ms using a zero-distance settle as a device for immediate arrival, which a distance-scaled duration correctly gives the floor. The invariant it exists for (hold opens with the animation, closes on arrival) is now asserted directly, and the magnitude check moved to the real-distance case in the same test.

Every behavioural claim above was mutation-verified: 40 mutations across the rounds, each turning exactly the intended tests red — including the ones that re-introduce a previous wrong answer (the exit borrowing the entry's curve, the softened-whole-curve variant, the absolute-position commit threshold, the one-frame velocity cliff, the travel-exceeds-width slack).

Full frontend suite green: 1677 files, 26489 tests, tsc -b and eslint clean.

Manual verification

Verified on a real phone across roughly ten rounds against a live gateway serving this branch's bundle, which is what drove most of the decisions here — every duration and curve in this PR is a device verdict, not a preference. Several diagnoses were overturned by that testing: a reported "snap" traced to the travel/width mismatch rather than to the curve it appeared to implicate, and a "no deceleration" report traced to a fixed duration being 1.5× faster than the swipe it was being compared against.

Screenshots / video

Mobile panel gestures at 390x844: a mid-screen rightward drag pulls the sessions drawer in under the finger, a hard leftward flick throws it shut, a mid-screen leftward drag brings the side panel in, a slow drag eases it back out, and the nav drawer opens from its own control

Same recording as mp4 (12s, 0.3MB)

recorded from 43f9f56c3d3d1539f8532b5c141af2a55698f5a1 · isolated pod at 390x844 · real touch events via CDP Input.dispatchTouchEvent · real server, no fixtures

In order: (1) a deliberate rightward drag begun at x=200 — mid-screen, which did nothing before this PR — pulls the drawer in under the finger and completes on release; (2) a hard leftward flick throws it shut, landing sooner than a gentle one would; (3) a leftward drag from mid-screen brings in the right-hand panel, which had no gesture at all; (4) a slow drag eases it back out, showing the glide into the edge; (5) the nav drawer, opened by its own control and dismissed by its scrim — a 231px panel, so its settle is proportionally shorter rather than sharing the drawer's duration.

The blue dot and its trail are a recording aid injected by the capture scenario, not app UI. They are painted by listening to the real touch events rather than written per step by the driver: writing them from the driver would add a round-trip to each interval the release-speed window is measured over, and clip (2) would have recorded slower than it was dispatched.

No static layout changes: the sessions drawer's rendered width is unchanged (its max-w-[calc(100vw-2.5rem)] class became the same number expressed through the existing width prop, so one constant now drives both the width and the travel), and the nav drawer's width is unchanged.

Risk / rollout

Frontend only, no config, no schema, no API. The gesture is bound only on mobile and only outside embed frames; desktop keeps its toggle and its own morph animation untouched. settleFor is shared, so the notification sheet — which #7022 moved onto animateDrawer — inherits the distance-scaled duration and the softer tap launch; its own tests pass unchanged.

Related

Builds directly on #6868, which converted the mobile right panel to a translateX overlay and put these settles on the compositor. Rebased past #7022, which retired the nc-slide-* CSS keyframes this branch had originally kept in step by hand — that pairing is now unnecessary, since the sheet goes through the same animateDrawer path.

@buluoray
buluoray requested a review from a team August 30, 2026 18:00
@buluoray
buluoray requested a review from a team as a code owner August 30, 2026 18:00
@buluoray
buluoray requested a review from CrysisDeu August 30, 2026 18:00
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 30, 2026
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of 43f9f56c3d3d1539f8532b5c141af2a55698f5a1 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

Direction-selected gestures, width-derived travel, and velocity-matched settles are the right decomposition; every tuning choice is device-verified, cross-gated, and pinned by mutation-tested contracts.

The one seam worth naming is already handled: settleFor is shared with the notification sheet, so its motion changes as a side effect — the PR states this and its tests hold, so it is an accepted coupling, not a gap.

[DESIGN-REVIEWED] 43f9f56

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 43f9f56c3d3d1539f8532b5c141af2a55698f5a1 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All checks are done. The change decomposes cleanly, every surface has counted consumers, the travel≠width root cause's siblings are all covered (the notification sheet already derives its travel from parkedOffset(), the right overlay genuinely is full-width), and temp-screenshots/ binaries are the repo's own PR-template convention. One zero-consumer micro-surface survives: settleFor's optional distance parameter, which both real call sites pass.

First-Principles-Verdict: PASS

Every item traces to a named, device-measured defect or a geometric fact, and the one duplication candidate (a safe-area reader) has no prior sibling.

What this change ships

Intent: make both mobile chat panels reachable by a swipe from anywhere, with a settle that responds to how hard they were thrown — an ADDITION, framed as one.

  1. Rightward drag from anywhere opens the sessions drawer (was a 24–120px band) — justified
  2. Leftward drag opens the right side panel, previously header-only — justified
  3. Each gesture disabled while the other panel is open — justified (direction can't disambiguate then)
  4. Opening drags refused in both platform edge bands, including the far bezel — declared, derived (OS gesture uncancellable)
  5. A release continues at the finger's speed; harder flicks land sooner — justified
  6. Tap dismissal slowed 240ms→450ms, now longer than the reveal — declared changed default
  7. Commit threshold: half the travel → a fifth, measured from gesture start — declared changed default
  8. Panels travel exactly their width + safe-area inset; scrim zero lands with the edge — cause-level; 0 unfixed siblings (sheet and right overlay already correct)
  9. Hook grows side (1 consumer), travel (1 consumer, distinct default at the other site), exported safeAreaLeft (2 consumers) — justified
  10. Demo gif/mp4 under temp-screenshots/ — repo convention (PR template, cleanup workflow)

Subtractions

  • Make settleFor's distance parameter required (useDrawerSwipe.ts:234) — zero callers omit it (2 call sites: lines 329, 485, both pass it), and its own comment concedes "every real path has it"; the optional branch is dead surface.

[FIRST-PRINCIPLES-REVIEWED] 43f9f56

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 43f9f56c3d3d1539f8532b5c141af2a55698f5a1 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 43f9f56

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 43f9f56c3d3d1539f8532b5c141af2a55698f5a1: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of 43f9f56c3d3d1539f8532b5c141af2a55698f5a1 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All evidence reviewed: the full diff, the demo GIF's rendered frame, and the surrounding ChatPage code. The gestures are additive paths that mirror existing visible controls (header toggles at both corners; the side panel's own header carries its close button), direction+axis-lock+scroller-deference gating matches platform drawer convention, the drag gives continuous finger-tracking feedback with cancellable drag-back, and no user-facing strings were added or changed. The one reachability change (sessions drawer gesture disabled while the side panel is open) trades a one-swipe overlap path for an unambiguous two-step, which is coherent rather than harmful.

UX-Verdict: PASS

Swipes now work from anywhere by direction, mirror visible button paths, track the finger continuously, and cancel cleanly — conventional, discoverable mobile drawer behavior.

[UX-REVIEWED] 43f9f56

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 43f9f56c3d3d1539f8532b5c141af2a55698f5a1 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 43f9f56

Verdict parsed from the review's SHA-scoped output markers for commit 43f9f56c3d3d1539f8532b5c141af2a55698f5a1.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 43f9f56c3d3d1539f8532b5c141af2a55698f5a1: <one-sentence reason>

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 30, 2026
@buluoray
buluoray force-pushed the feat/mobile-swipe-both-panels branch from d67c9d0 to 7ef62bf Compare August 30, 2026 18:35
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: passed Eligible automated validation passed for the current revision readiness: checking Automated validation is still running labels Aug 30, 2026
The mobile chat had one gesture, and it was hard to find: the sessions
drawer armed only inside a 24-120px band at the left edge -- the leftmost
quarter of a 390px phone, so a drag begun where a thumb lands did nothing
-- and the right-hand side panel had no gesture at all.

Both panels now open and close by drag, from anywhere in the pane except
the platform's own two edge bands. DIRECTION selects the panel rather than
the start position, which is what lets one binding serve each panel and
lets the gesture be found; each instance is disabled while the other's
panel is on screen, because once a panel is open its closing drag is the
other's opening drag.

The settle became a function of the input rather than a constant. A
release continues the finger: it decelerates from the speed the finger
let go at, so a harder flick lands sooner, measured across a 100ms window
rather than the final pair of samples (a flick whose lift landed a frame
late used to have its whole speed discarded and settled exactly like a
gentle drag). A tap has no velocity to continue, so it plays the slowest
reading of a release, launching gently because it starts from rest --
the same curve as a release put 31.6px on screen in the first painted
frame, which reads as a jump from a standstill but as continuity behind
a moving finger.

Durations are held as a SPEED. A number tuned on a panel crossing most
of the screen makes a narrow one crawl: the nav drawer travels 231px
against the sessions drawer's 350px and moved 1.46x slower per pixel for
the same duration. Each settle now scales by the distance it has to
cover, so every panel moves at one speed.

Two panels also travelled further than they are wide, which spent the
deceleration on something already off the screen -- the sessions drawer
was fully hidden at 90% of its slide, the nav drawer at 96%. Their travel
now comes from their own width, so the panel's edge and the scrim's zero
land on the same frame.

The commit threshold is measured from where the gesture started rather
than from the panel's absolute position, which is what lets one fifth of
the travel mean the same light ask in both directions.
@buluoray
buluoray force-pushed the feat/mobile-swipe-both-panels branch from 7ef62bf to 43f9f56 Compare August 30, 2026 19:19
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention readiness: passed Eligible automated validation passed for the current revision and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 30, 2026
@bolichen97
bolichen97 enabled auto-merge August 30, 2026 20:27
@buluoray
buluoray merged commit 5791a47 into main Aug 30, 2026
112 of 117 checks passed
@buluoray
buluoray deleted the feat/mobile-swipe-both-panels branch August 30, 2026 20:41
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 30, 2026

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full-diff maintainer review passed: change matches its stated scope, no regressions or trust-boundary weakening found, checks green and no outstanding change requests. Approving.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants