fix(chat): hand a mobile panel's swipe to its sibling at release - #7320
Conversation
The chat page binds `useDrawerSwipe` twice on one element — sessions drawer left, side panel right — and each instance is enabled only while the other panel is off screen, because an open panel's closing drag is the other's opening drag. That gate was spelled `phase === 'closed'` and the phase only reached `'closed'` from `onSettle`, which runs in the settle animation's completion callback. So the exclusion stayed shut for the whole ~300ms slide: after swiping one panel away you could not swipe the other into view until an animation you had already finished driving had played out. The gesture path was the worse of the two: it never entered `'closing'` at all. `beginDrawerDrag` set `'open'` and `onSettle(false)` jumped straight to `'closed'` at the end, so the phase read `'open'` for the entire slide. The tap path (`closeSidebar`, e.g. selecting a session) did set `'closing'`, and was blocked anyway because `'closing'` is not `'closed'`. Gate on intent instead of arrival: - `useDrawerSwipe` gains an optional `onCommit(open)`, fired where the release decision is made rather than when the panel finishes arriving. `onSettle` keeps its contract — it waits for the animation so a consumer cannot unmount a panel mid-slide, which is exactly why it is the wrong signal for a gate. - A committed close parks the phase at `'closing'`, not `'closed'`: the panel is still on screen and its mount predicate keys on `!== 'closed'`. - Both gates relax to `phase !== 'open'`. The hazard lasts exactly as long as the sibling is open. No chrome timing changes: the tap path already flipped these phases at the start of its slide, so the gesture path now matches it rather than introducing new behaviour. The effect that slides the right overlay out guards on `!== 'open'`, so parking at `'closing'` makes it return early instead of starting a second animation over the hook's own settle.
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Removes a dead ~300ms window after dismissing a mobile panel — the follow-up swipe now works immediately, with the open-panel exclusion still intact. [UX-REVIEWED] 00e9f5c |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix — gating exclusion on commit intent instead of animation arrival — with the right seam (optional [DESIGN-REVIEWED] 00e9f5c |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified against the repo. The hook's third consumer (App.tsx shell nav) has no sibling exclusion gate, so no unfixed siblings; the only two arrival-keyed gates were the two this PR fixes. Composing the review now. First-Principles-Verdict: PASS A reported on-device defect, fixed at its cause — the exclusion gate keyed on arrival instead of intent — with every rider declared in the description. What this change shipsIntent: let a phone user swipe open the second chat panel immediately after swiping the first away. FIX.
Sibling count: grepped Subtractions
[FIRST-PRINCIPLES-REVIEWED] 00e9f5c |
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: |
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: |
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (5 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: mobile swipe gesture gate keyed on the sibling panel's phase (!== 'open') with release reported at commit time via onCommit; no auth/input/trust/sandbox/gate/secret mechanism touched.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (5 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: mobile chat drawer swipe hands release to its sibling panel via onCommit (intent-keyed) instead of onSettle (arrival-keyed), so a dismissing swipe can be followed immediately by the opening swipe — pure client-side gesture-timing fix in useDrawerSwipe/ChatPage with two regression tests.
Problem
Reported by @Rayrayxu on a phone, after the app-wide nav swipe landed in #7133: in chat, swiping one mobile panel away and then immediately swiping to reveal the other one does nothing — you have to wait for the first panel's slide-out to finish before the second gesture is accepted.
The chat page binds
useDrawerSwipetwice on one element (sessions drawer on the left, side panel on the right), and each instance isenabledonly while the other panel is off screen. That exclusion is necessary: while both panels are closed DIRECTION separates them, but once one is open it cannot, because the open panel's closing drag is the other panel's opening drag. Without the gate, one leftward drag would dismiss the left drawer and summon the right panel at the same time.The gate was spelled
phase === 'closed', and the phase only reaches'closed'fromonSettle— which runs in the settle animation's completion callback. So the exclusion stayed shut for the entire ~300ms slide, gating a subsequent gesture on an animation the user had already finished driving.Two paths, and the gesture one was worse:
'closing'at all.beginDrawerDragsets'open', andonSettle(false)jumps straight to'closed'at the end — so the phase read'open'for the whole slide.'closing'(closeSidebar, e.g. selecting a session collapses the drawer) — and was blocked anyway, since'closing'is not'closed'. So the same wait existed on a path nobody had reported yet.Fix
Gate on what the panel is committed to, not on what it has arrived at.
useDrawerSwipegains an optionalonCommit(open), fired where the release decision is made rather than when the panel finishes arriving.onSettlekeeps its contract unchanged — it deliberately waits for the animation so a consumer cannot unmount a panel mid-slide, which is exactly what makes it the wrong signal for a gate.'closing', not'closed'. The panel is still on screen and its mount predicate keys on!== 'closed', so writing'closed'here would cut the slide short — the snap this phase machine exists to avoid.phase !== 'open'. The hazard lasts exactly as long as the sibling is open, which is what the exclusion is for.Why this does not shift anything else
Two things worth stating because they are what makes the change safe rather than merely small:
mobileSessions(phase === 'open') feeds the sessions toggle icon, the empty-chat floating entry and a separator. The tap path already flipped the phase to'closing'at the start of its slide, so those already changed at slide-start; the gesture path now matches it rather than introducing new timing.sideOverlayPhaseRef.current !== 'open', so parking at'closing'makes it return early. Previously a phase stuck at'open'through the slide meant a concurrentcloseSidebar()could start a competinganimateDrawerover the hook's own settle; that race is now closed rather than opened.Tests
drawerSwipeTwoPanels.test.tsgets a harness that models the consumer's real state machine ('open' | 'closing' | 'closed', gated!== 'open', release viaonCommit) instead of the static boolean the existing cases use, because a static boolean cannot express the distinction this PR is about:'closing'andonSettlehas not fired, then drives a fresh leftward drag and expects the right panel to open.mobilePanels.compositor.test.ts's existing source-guard was pinning the old literal; it is updated, not deleted, to assert!== 'open'plus the presence ofonCommitand a'closing'park.Mutations verified to redden:
onCommitnever fired; fired with the inverted boolean; each gate reverted to=== 'closed'; each side'sonCommitparking at'closed'instead of'closing'; the right side'sonCommitdeleted outright.Two mutations I am reporting rather than papering over:
'closing'park was initially unpinned because my own guard matched my own comment. The options block explains "'closing'rather than'closed'because…", so a/'closing'/assertion was satisfied by the prose instead of the code. Fixed by stripping comments and matchingset*Phase('closing')— the same self-referential-guard trap that has bitten this repo's anti-drift tests before.onCommitto aftersettle(...)reddens nothing, and that is correct. Both run in the same synchronous block before any frame, and React batches the consumer'ssetStateeither way, so there is no observable ordering difference. No honest test distinguishes them and I did not invent one; the placement beforesettleis for reading order only.tsc -b, eslint anddocs-lint.shclean; the drawer, ownership, two-panel, settle and compositor suites green (124 cases).Pattern harvest
Rule candidate: agents-md / review-prompt — documented in
website/docs/page-layout.mdin this same commit.Pattern: a gesture predicate reading state that is only written in an animation's COMPLETION callback, so it lags by the length of a settle.
This defect class has now been fixed twice, one layer apart, which is what makes it a pattern rather than a one-off. #7133 fixed it inside this hook —
useDrawerSwipe.tskeeps its ownopenRefprecisely because theopenprop lags a settle, and its comment spells out the failure ("a re-opening drag read as an opening drag on an open panel and was declined"). This PR fixes the identical mechanism in the consumer: the chat page's exclusion gate read a phase that only reaches'closed'fromonSettle. The hook was immune and the caller was not, because the fix lived in the hook's private ref instead of in a rule anyone writing a caller would meet.Not a semgrep rule: catching it mechanically requires knowing which values are settle-derived, and a syntactic proxy (
=== 'closed'near anenabled:) would be both leaky and noisy. The durable artifact is the doc rule this commit adds to the gesture-ownership section — the same section that already carries thedata-owns-swipeand bind-LIVE rules from #7133, stated positively so the next caller meets it before writing the gate: gate on intent (!== 'open'), release throughonCommit, and never spell it=== 'closed'. It also names whyonSettlemust keep waiting for the animation, so the rule cannot be "fixed" by makingonSettlefire earlier.Why no screenshot: there is no visual delta — same panels, same offsets, same animations. What changed is when a gesture is accepted, which a still frame cannot show. The behaviour is pinned by the two new tests above and was verified on-device by the reporter.
Why no linked issue: reported directly during device testing.