feat(chat): swipe both mobile panels, and settle by distance and speed - #7073
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of 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: [DESIGN-REVIEWED] 43f9f56 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of 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 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 shipsIntent: 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.
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 43f9f56 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — ✅ PASSUX-level review of 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 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
d67c9d0 to
7ef62bf
Compare
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.
7ef62bf to
43f9f56
Compare
bolichen97
left a comment
There was a problem hiding this comment.
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.
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
touchmoveused 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 atleft-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 resolvingenv()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; theenabledcross-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 -band 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
Same recording as mp4 (12s, 0.3MB)
recorded from 43f9f56c3d3d1539f8532b5c141af2a55698f5a1 · isolated pod at 390x844 · real touch events via CDPInput.dispatchTouchEvent· real server, no fixturesIn 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 existingwidthprop, 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.
settleForis shared, so the notification sheet — which #7022 moved ontoanimateDrawer— 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 sameanimateDrawerpath.