fix(chat): let Back close the mobile sessions drawer - #7295
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of All evidence gathered. The change is behavioral only — no new user-facing strings, no layout changes — and it aligns the mobile drawer with the platform convention that Back dismisses a layered surface. The tests pin the flows that would have degraded the experience (invisible no-op Back, resurrected outgoing session, Back during the opening slide). The one coherence signal: the mirrored right-hand activity overlay in the same file keeps the old behavior, so Back dismisses one overlay but exits the chat over the other. UX-Verdict: PASS Back now dismisses the mobile sessions drawer instead of ejecting the user from /chat — matching platform convention, with the no-op-Back and resurrected-session traps explicitly closed. Suggestions
[UX-REVIEWED] 2d592ff |
Design Review (Fable 5) — ✅ PASSDesign-level review of All checks done. The design here is sound: a real, platform-conventional UX problem (mobile Back should dismiss a screen-covering layer), solved with the standard one-entry-per-layer pattern, with the two known interaction hazards (the Design-Verdict: PASS One history entry whose lifetime equals the drawer's is the platform-correct shape; both known hazards of that shape are handled and pinned by mutation-verified tests. [DESIGN-REVIEWED] 2d592ff |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All checks are done: the recordings are a documented repo convention, the near-duplicate mechanism ( First-Principles-Verdict: PASS Every item is the fix or its mutation-tested bookkeeping; nothing rides along, and the near-duplicate mechanism is meaningfully, verifiably different. What this change shipsIntent: on a phone, the platform Back gesture should dismiss the open sessions drawer instead of leaving
Duplication check I ran: No blockers, no subtractions: consumer counts are 2–3 per new helper, and every guard's deletion reddens a named test. [FIRST-PRINCIPLES-REVIEWED] 2d592ff |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've analyzed the single candidate and the full diff. Candidate 1 (twin-entry survives reload): The candidate's own confidence is "low," and it explicitly requires assuming browser history-stack persistence across a hard reload — behavior not present in any code I can open (MemoryRouter has no persistent stack, as the candidate concedes). The scenario is a deliberately documented tradeoff: the code comment at the I traced the new flag mechanics ( No findings. [OPUS-REVIEWED] 2d592ff 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: |
588f190 to
98bd1c4
Compare
98bd1c4 to
b6db610
Compare
|
First Principles blocker taken, and the advisory subtraction with it.
The "inherited, not derived" reading in the Watch section is correct and is the part worth recording. Verification after the subtraction: 4/4 in The recording was made at |
|
On the CONCERNS Watch items, for anyone reading the thread later: Description/diff drift -- already fixed, the review read the previous body. The marker subtraction and the description rewrite were two separate operations: I pushed The single Counted siblings -- accepted as deferred, and I agree with the deadline framing. Worth pinning the two you found that I had not: the file and diff panels in |
buluoray
left a comment
There was a problem hiding this comment.
Reviewed at b6db61010. 1 blocking / 1 yellow / 2 blue — comment only. The blocker is the merge conflict, not a code defect: the change itself is clean and its tests are mutation-verified.
The code is right
Making the drawer own exactly one history entry for its lifetime — push a bare duplicate on open, pop it on Back, spend it via navigate(-1) on every other close path — is the correct shape, and I verified the mechanism against origin/main rather than the description:
- The URL-sync write effect's early return (
if (current === activeSlot && location.pathname === expectedPath) return, main:4058) is why the duplicate PUSH does not fire a redundant navigate, sonavigationTypecorrectly stays PUSH. That is the load-bearing detail the whole approach rests on. popInFlightRef(main:4044) shields genuine session Back/Forward, and the new POP effect is gated ondrawerPhaseRef === 'open'so it cannot fire during session navigation.- Every
closeSidebarcaller (session switch, backdrop, header toggle,selectSource, preview-expand) inherits consumption becausecloseSidebaritself was redefined to callconsumeDrawerEntry— so the close paths are covered by construction rather than by enumeration. - No Escape handler for this drawer exists on main, so Escape is not a missed close path. I checked because it is the usual gap in this pattern.
All 8 load-bearing body claims are true against source; none is false. AUTOSDE blocking rules are clean — the production diff adds no user-facing strings (comments only), no icons, no buttons, no dangerouslySetInnerHTML, no layout changes, no locale-less formatting. Mutations reproduce your table: neutralizing the POP-closes-drawer effect fails "Back closes the drawer"; making pushDrawerEntry a no-op fails both "opening PUSHES" and "Back closes".
BLOCKING — conflicting, and the CI on it is stale
gh api reports mergeable=false, mergeable_state=dirty. A read-only git merge-tree origin/main b6db610 produces exactly one conflict: website/src/pages/ChatPage.tsx, from #7207 ("stop chat transcript blanking content above the viewport", cec5f7146) landing after the fork.
Two consequences worth stating explicitly:
The 78 green check-runs do not mean what they look like. A CONFLICTING PR cannot regenerate refs/pull/N/merge, so no pull_request-event workflow has run against the tip merged with current main. Those greens are from a pre-conflict SHA.
The rebase is not mechanical. Main also gained #7320 ("hand a mobile panel's swipe to its sibling at release"), which restructured the drawer gesture config into onCommit + onSettle (main:7031-7032), whereas this PR edits an onSettle-only base. So the rebase has to wire pushDrawerEntry/consumeDrawerEntry into both hooks correctly and reconcile #7207 — after which the tests and the mutation table need re-running on the rebased tip, because what merges will be materially different from what I reviewed.
Yellow
ChatPage.tsx:6788 — a genuine PUSH navigation away while the drawer is open unmounts ChatPage with drawerEntryRef=true, orphaning the pushed entry and leaving a phantom duplicate /chat entry, i.e. one dead Back after returning. This is the same mechanism as the reload-while-open edge you acknowledge as unfixable-by-design, but it is not among the enumerated close paths. Reachability is low on mobile — the drawer covers the screen and normal actions route through closeSidebar or the POP effect — so it is non-blocking, but worth confirming no programmatic push-away path exists while the sessions drawer is open.
Blue
Four binary evidence files are committed (temp-screenshots/mobile-back-drawer-5795/*.mp4 plus 2 .gif). The GIFs already serve the description, so the two .mp4s are usually worth stripping before merge.
Side panel, file viewer and diff view have the same "pushes nothing" defect, deliberately out of scope — and I agree with your note that the third instance is the point to lift the push/spend pair into a shared hook rather than hand-roll it again.
Rebase onto main (reconciling #7207 and #7320), re-run the tests and the mutation table on the rebased tip, and I will re-review. Nothing in the code itself needs changing.
b6db610 to
db7c0a6
Compare
|
Rebased onto current main after the 13-PR merge batch put this branch in conflict; the conflict is resolved and the PR is MERGEABLE again. The remaining reds on this head are main-owned and reproduce on PRs with disjoint diffs, so they are not actionable here:
Per house rule the main-owned fixes are not being folded in here. Once main heals I will rebase to cut a fresh merge ref and re-run, rather than re-triggering against a stale one. |
db7c0a6 to
9916ab6
Compare
The drawer was pure component state, present in no history entry, so a platform back swipe with it open left /chat entirely instead of dismissing the panel covering the screen. Mint one history entry per open, a bare duplicate of the entry below it, so the pop that closes the drawer moves no session. Every other close spends the entry, the sid effect learns to ignore that bookkeeping pop so consuming it on a session switch cannot resurrect the outgoing session, and the URL-sync effect keys on location.key so it still corrects ?sid after a same-pathname pop. For a drag, both the mint and the spend sit on the gesture's release (onCommit) rather than its arrival (onSettle). onSettle deliberately waits out the 120-450ms slide so a consumer cannot unmount the panel mid-animation, which makes it the wrong signal for an entry standing for intent: minting there left the whole opening slide with the panel covering the screen and no entry to pop, so a Back leaked past the drawer and left /chat exactly as before. Spending there had the mirror hazard, a Back during the closing slide popping the still-unspent entry while the POP effect ignored it, with the settle then spending a second, real entry. Fixes #5795
9916ab6 to
2d592ff
Compare
|
|
Answering the two advisory bullets in the UX Review for 1. "the mirrored right-hand activity overlay ... Back over it still leaves /chat" - CORRECT, and deliberately out of scope. Verified rather than assumed: every call site of the two entry helpers is a LEFT-drawer path ( Not folded in, for three reasons. #5795 names the sessions drawer, so this is a second defect rather than an unfinished half of this one. The right overlay's open state is not symmetric with the drawer's: it is gated on 2. "Both demo GIFs open on a near-black frame" - accepted, not fixed in this revision. A fair presentation criticism: the PR page shows two dark rectangles until playback. It is also purely cosmetic and lives entirely in Neither item blocks: the verdict is |
buluoray
left a comment
There was a problem hiding this comment.
Verdict: 0 blocking, 2 non-blocking. Reviewed at head 2d592ffb4b010336bbb3d931ea8b861bfeb40174.
What I verified
- Read the full diff: only
website/src/pages/ChatPage.tsxand the newwebsite/src/test/ChatPage.drawerBackClose.test.tsxcarry code; the other four files aretemp-screenshots/media (documented repo convention). - Entry lifetime is balanced (no accumulation).
pushDrawerEntryearly-returns whendrawerEntryRef.currentis already set, andconsumeDrawerEntryearly-returns when it is not. One entry maximum per open; open mints, every non-Back close spends. Verified all close paths reachconsumeDrawerEntry: backdrop tap and header toggle viacloseSidebar->runDrawerClose+consumeDrawerEntry; drag-close viaonCommit(open=false); session switch via theactiveSloteffect ->closeSidebar; and the mobile->desktop crossing effect (if (!isMobile) { setDrawerPhase('closed'); consumeDrawerEntry() }). - Back path does not double-consume. The POP-close effect clears
drawerEntryRefand callsrunDrawerClose()only (nonavigate(-1)) — the pop is the consumption. WhencloseSidebartriggers its ownnavigate(-1), the resulting POP hits the effect but both guards (drawerEntryRef.currentalready false,drawerPhaseRef.current === 'closing'not'open') make it a no-op. No off-by-one. - Stale
?sid=guard. On a session-switch close,drawerPopRefis set beforenavigate(-1); the?sid=->activeSloteffect checks and clears it before the arming block (so the early-returningpopReadyRef/duplicate-key guards cannot strand it), preventing the outgoing session from being resurrected. Test 3 pins exactly this. - Desktop unaffected.
pushDrawerEntryearly-returns on!isMobile, sodrawerEntryRefnever becomes true off-mobile;consumeDrawerEntryand the POP-close effect are therefore no-ops on desktop. - Panel mutual exclusivity is intact and untouched. The right-side gesture (
ChatPage.tsx:7202on main) is gated... && drawerPhase !== 'open', so it cannot fire while the left drawer is open; the PR modifies only the leftuseDrawerSwipeonCommit/onSettle, leaving the right overlay binding unchanged. - No manual popstate listener is added — the fix rides react-router's
useNavigationType()(ChatPage.tsx:967) + alocation.key-keyed effect, so there is no listener to leak against a dead component. On the initial rendernavigationTypeis'POP'butdrawerEntryRefis false, so the effect early-returns. - i18n: the diff adds no JSX and no user-facing strings (only refs, callbacks, effects, and comments), so there is nothing new to route through the catalog.
- AUTOSDE.yaml blocking rules: none implicated — no layout/gutter change (page-layout-pattern), no icons (use-lucide-icons/icon-buttons-need-labels/no-emoji-as-icons), no new interactive elements (accessible-interactive-elements), no security surface (frontend-security). Automated Rule Check is green.
- All 69 checks green at this SHA; all five review lanes (GPT 5.6, Opus 4.8, Design, UX, First Principles) read PASS/no-blocking with bodies citing this exact SHA.
Non-blocking findings
- PR body / test-count drift (PR description, section 4): the body says the new file has "(4 cases)" but
ChatPage.drawerBackClose.test.tsxcontains 5it()blocks (the drag-open-at-release case is the 5th). Consequence: a reviewer counting cases against the description sees a mismatch. Suggestion: update the body to "(5 cases)". Pure prose edit, no SHA change. (First Principles already noted this as declared.) - Documented residue — one dead Back after reload/navigate-away with the drawer open (
ChatPage.tsxdrawerEntryRefdeclaration + POP-close effect): if the component unmounts (hard reload, or leaving/chat) while the entry is still held, the entry is left on the stack, costing one invisible no-op Back press. Consequence: a single silent Back in a narrow window. This is disclosed in the PR body and in thedrawerEntryRefcomment, and is the same tradeoffSUBNAV_PUSH_STATEcarries. Suggestion: none required; acceptable as declared.
What I could not verify
- I did not execute the vitest suite or the mutation reverts (read-only shared checkout, no build). I relied on reading the test file plus the green "Frontend Tests" checks and the author's documented mutation table. The test structure (real touch gesture for the drag case,
sseConnected()armed so the?sid=effect is actually exercised, manual settle draining) is consistent with the claimed guards. - The mirrored right-hand activity overlay still leaves
/chaton Back (it owns no history entry). This is a pre-existing sibling of #5795, not introduced here, and is correctly deferred to a shared-hook follow-up per the UX Review thread — out of scope for this PR.
1. What is the problem?
On a phone, the sessions drawer covers the screen, and Back is the gesture a user reaches for to dismiss a panel that covers the screen. The drawer was pure component state (
drawerPhase), present in no history entry, so a back swipe with it open left/chatentirely -- and the drawer was still open when they navigated back.Nothing about this changed in the drawer work that landed since the issue was filed: #7133 added the swipe gesture to every page and touched no history at all, and there is still no
popstatehandling anywhere in the drawer's path onmain. Confirmed two ways -- the two failing cases in the new test file on the pre-fix tree (opening the drawer reportsREPLACE, and Back leaves the route), and the before/after recording below.2. Why this issue matters to the user
Back is the only dismissal affordance the mobile layout does not have to teach. The drawer has three others (leftward drag, backdrop tap, header toggle), so this is a papercut rather than a trap -- but the papercut is paid by the user who reaches for the gesture the platform trained them on, and it costs them their place in the app rather than just doing nothing.
3. How our fix solves it
The drawer now owns exactly one history entry, and it exists exactly while the drawer is open. That single invariant is what makes each step follow:
?sid=is exactly what the sid effect misreads.navigate(-1)): backdrop tap, header toggle, gesture settle, session switch, and the crossing out of the mobile viewport. Leaving it behind would be the twin-entry defectSidePanelLayout's own back control documents -- two entries with the same URL, so the next back-swipe visibly does nothing.The chain from that to the two effects the issue said would need rework:
?sid=, andswitchSlot.pendinghas already movedactiveSlot. The?sid=->activeSloteffect treats any POP as the user retracing sessions, so it would switch them straight back to the session they just left.drawerPopRefmarks that pop as bookkeeping. It is checked before the arming block, not after, because thepopReadyRefand duplicate-key guards there return early and would leave the flag set to swallow a later genuine POP.activeSlot->?sid=effect could not see -- so it did not re-run and left?sid=naming the outgoing session, which a reload would then restore. It now keys onlocation.keyas well: the key changes on any history move, which is the thing that actually happened. POPs are still funnelled through the existingpopInFlightRefbail, so this adds a re-check rather than a new writer.Ownership is a ref, and the entry carries no
history.statemarker. That is a deliberate difference fromSUBNAV_PUSH_STATE: a SubNav drill-in changes the URL, so a cold deep link can land on the drilled-in entry and the marker is the only way to tell "we pushed this" from "the user arrived here". Nothing can deep-link a drawer open, so there is no such question, and a marker would be write-only state. A ref is also the only correct form -- a marked entry can outlive the mount that pushed it (a reload restoreshistory.state, and Forward can walk back into one), so reading a marker would have the page consume an entry it never pushed.4. What tests we did
Recording
Same scenario both times, on a real pod running this branch at 390x844: open the sessions drawer by its own control, then press platform Back twice.
The driver logged the URL and the scrim's presence at each step, which is the part a GIF cannot assert:
drawer-openkeeping the URL byte-identical is the other half of the contract: the pushed entry is a duplicate, so nothing about the session moves.recorded from 98bd1c4 - kirodotdev/KiroCrew fix/mobile-back-closes-drawer-5795 - mode: pod
kirocrew-fix-5795:8005, viewport 390x844, headless chromium - real server, no fixtures; the BEFORE clip is main'sChatPage.tsxbuilt into the same pod (verified by the absence ofchatDrawerPushin the served bundle). The head has since moved to b6db610, whose only change is deleting the inerthistory.statemarker -- zero production readers, as the First Principles review independently grepped -- so the recorded behaviour is unchanged. Re-record from either SHA to check.Automated
New
src/test/ChatPage.drawerBackClose.test.tsx(4 cases). Two are RED on the pre-fix tree; the other two were green there only because there was no entry to mismanage, and both caught a real defect in this change (the stale?sid=above).Every guard is mutation-verified -- each reverted in turn, each reddens a case:
drawerPopRefguard deletedlocation.keydep removedThe harness arms
sseConnected()deliberately: the?sid=effect returns early while offline, and the first version of this file passed with thedrawerPopRefguard deleted because it never reached the revert.Neighbouring suites, re-run after the marker subtraction:
ChatPage.sid(36),ChatPage.drawerFrameBudget(2),sessionUrlHistory(3),ChatSidebar.historyDeepLink(3),ChatPage.embedded(3) -- 47 passed, plusChatPage.drawerBackClose(4). Earlier:ChatPageCoverage+ChatPageDrafts(75).tsc -bclean. eslint 13 warnings onChatPage.tsxboth before and after and 0 on the new test, so the--max-warnings 659ratchet is untouched.5. Any other suggestions on the work
SUBNAV_PUSH_STATE: reloading while the drawer is open leaves the pushed entry behind, since an entry you are standing on cannot be removed except by leaving it. Cost is one dead Back press after that specific reload.push on open / spend on closepair lifted into a shared hook rather than four copies.Pattern harvest
Rule candidate: review checklist (not mechanically greppable)
Pattern: a mobile layer that covers the screen must own a history entry, and that entry's lifetime must equal the layer's. Both halves are load-bearing and both were wrong here in different ways: no entry at all is this bug, and an entry that outlives its layer is the twin-entry dead-Back defect
SidePanelLayout's back control already documents. The issue names three more layers that push nothing (side panel, file viewer, diff view), so the next one should lift the pair into a shared hook rather than add a third hand-rolled copy.Second, narrower candidate, worth a lint rule if it recurs: an effect that writes the URL from state must key on
location.key, not onlylocation.pathname. A history move between two entries with the same pathname is invisible to a pathname dep, and the effect then leaves the URL describing state that is no longer current. That is a silent class of bug -- nothing throws, and it only surfaces on reload.Fixes #5795