Skip to content

fix(chat): hand a mobile panel's swipe to its sibling at release - #7320

Merged
bolichen97 merged 1 commit into
mainfrom
fix/chat-panel-handoff-during-settle
Aug 31, 2026
Merged

fix(chat): hand a mobile panel's swipe to its sibling at release#7320
bolichen97 merged 1 commit into
mainfrom
fix/chat-panel-handoff-during-settle

Conversation

@buluoray

@buluoray buluoray commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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 useDrawerSwipe twice on one element (sessions drawer on the left, side panel on the right), and each instance is enabled only 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' from onSettle — 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:

  • Gesture close never entered 'closing' at all. beginDrawerDrag sets 'open', and onSettle(false) jumps straight to 'closed' at the end — so the phase read 'open' for the whole slide.
  • Tap close did set '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.

  • useDrawerSwipe gains an optional onCommit(open), fired where the release decision is made rather than when the panel finishes arriving. onSettle keeps 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.
  • A committed close parks the phase at '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.
  • Both gates relax to 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:

  • No chrome timing change. 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.
  • No second animation. The effect that slides the right overlay out guards its else-branch on sideOverlayPhaseRef.current !== 'open', so parking at 'closing' makes it return early. Previously a phase stuck at 'open' through the slide meant a concurrent closeSidebar() could start a competing animateDrawer over the hook's own settle; that race is now closed rather than opened.

Tests

drawerSwipeTwoPanels.test.ts gets a harness that models the consumer's real state machine ('open' | 'closing' | 'closed', gated !== 'open', release via onCommit) instead of the static boolean the existing cases use, because a static boolean cannot express the distinction this PR is about:

  • the right panel arms immediately after the left is swiped shut, mid-settle — asserts the phase is 'closing' and onSettle has not fired, then drives a fresh leftward drag and expects the right panel to open.
  • while a panel is genuinely OPEN the far instance stays unbound — the control, so the exclusion still holds for the case it exists for.

mobilePanels.compositor.test.ts's existing source-guard was pinning the old literal; it is updated, not deleted, to assert !== 'open' plus the presence of onCommit and a 'closing' park.

Mutations verified to redden: onCommit never fired; fired with the inverted boolean; each gate reverted to === 'closed'; each side's onCommit parking at 'closed' instead of 'closing'; the right side's onCommit deleted outright.

Two mutations I am reporting rather than papering over:

  • The '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 matching set*Phase('closing') — the same self-referential-guard trap that has bitten this repo's anti-drift tests before.
  • Moving onCommit to after settle(...) reddens nothing, and that is correct. Both run in the same synchronous block before any frame, and React batches the consumer's setState either way, so there is no observable ordering difference. No honest test distinguishes them and I did not invent one; the placement before settle is for reading order only.

tsc -b, eslint and docs-lint.sh clean; 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.md in 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.ts keeps its own openRef precisely because the open prop 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' from onSettle. 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 an enabled:) 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 the data-owns-swipe and bind-LIVE rules from #7133, stated positively so the next caller meets it before writing the gate: gate on intent (!== 'open'), release through onCommit, and never spell it === 'closed'. It also names why onSettle must keep waiting for the animation, so the rule cannot be "fixed" by making onSettle fire 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.

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.
@buluoray
buluoray requested a review from a team August 31, 2026 17:27
@buluoray
buluoray requested a review from a team as a code owner August 31, 2026 17:27
@buluoray
buluoray requested a review from smeyffret August 31, 2026 17:27
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

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

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

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

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

Design-Verdict: PASS

Root-cause fix — gating exclusion on commit intent instead of animation arrival — with the right seam (optional onCommit, onSettle contract untouched) and docs updated in-commit.

[DESIGN-REVIEWED] 00e9f5c

@github-actions

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 00e9f5cb70231c275df2a702b95f71ded5246100 — 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 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 ships

Intent: let a phone user swipe open the second chat panel immediately after swiping the first away. FIX.

  1. A swipe revealing the sibling panel is accepted mid-slide, not after ~300ms — justified (reported defect).
  2. Same unblocking on the tap-close path (select a session, then swipe) — declared, same cause.
  3. Drawer-derived chrome (toggle icon, empty-chat entry) flips at slide-start on gesture close, matching the tap path — declared, necessary consequence.
  4. useDrawerSwipe gains optional onCommit — justified: 2 consumers (ChatPage:6895, 6922), and no existing release-time signal exists (onSettle is contractually arrival-keyed).
  5. A tap-close during a gesture slide no longer races a second animateDrawer (closeSidebar early-returns on 'closing', ChatPage:6744) — declared, rides along.
  6. page-layout.md documents the intent-not-arrival rule — mandated (website AGENTS.md same-commit doc rule).
  7. Two behavioral tests plus retargeted source guards — justified; the old guard pinned the defect's literal.

Sibling count: grepped enabled:.*Phase gates on useDrawerSwipe's 3 non-test call sites — only the two ChatPage instances carry the exclusion gate, both fixed; the App.tsx shell instance has none. Zero unfixed siblings.

Subtractions

  • Shrink onCommit?: (open: boolean) to a close-only callback: the open === true case has 0 consumers (both ChatPage handlers early-return on it; grep onCommit in website/src — 2 call sites), and the opening decision already reaches consumers through onGestureOpen. The boolean exists only as symmetry with onSettle.

[FIRST-PRINCIPLES-REVIEWED] 00e9f5c

@github-actions

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 00e9f5cb70231c275df2a702b95f71ded5246100 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 00e9f5c

Verdict parsed from the review's SHA-scoped output markers for commit 00e9f5cb70231c275df2a702b95f71ded5246100.

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

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 00e9f5cb70231c275df2a702b95f71ded5246100 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 00e9f5c

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

@github-actions github-actions Bot added readiness: checking Automated validation is still running 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 31, 2026
@bolichen97
bolichen97 enabled auto-merge (squash) August 31, 2026 18:03

@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.

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
bolichen97 merged commit c7a9992 into main Aug 31, 2026
74 of 77 checks passed
@bolichen97
bolichen97 deleted the fix/chat-panel-handoff-during-settle branch August 31, 2026 18:04

@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.

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.

@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 31, 2026
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.

2 participants