@@ -15,14 +15,14 @@ const CARD_RADIUS = Platform.OS === "ios" ? 53 : 0;
1515 * new-session create page).
1616 *
1717 * Bare `react-native-drawer-layout`, deliberately NOT a drawer navigator:
18- * the drawer hosts no routes (its sole child was ever one screen group),
19- * and the navigator hid `open` inside navigation state — the close
20- * animation could only be observed via parent-navigator event listeners
21- * with no queryable "is animating" (which forced a safety timeout in the
22- * session screen's mount gate). As a plain React `closing` flag (set in
23- * closeDrawer, cleared on the drawer's onTransitionEnd) the gate is exact.
24- * Visuals are identical — the navigator was a thin wrapper around this
25- * same component.
18+ * the drawer hosts no routes (its sole child is one screen group), and the
19+ * navigator hid `open` inside navigation state — the close animation could
20+ * only be observed via parent-navigator event listeners with no queryable
21+ * "is animating" (which forced a safety timeout in the session screen's mount
22+ * gate). As a plain React `transitioningSessionId` (set in closeDrawer, cleared
23+ * on the drawer's onTransitionEnd) the gate is exact, and `open` drives the
24+ * commit-point haptics below. Visuals are identical — the navigator was a thin
25+ * wrapper around this same component.
2626 *
2727 * Sole child is the `(stack)` group (see ./(stack)/_layout.tsx) — its
2828 * inner Stack contains both the new-session page (`index` → URL `/`) and
@@ -50,81 +50,86 @@ const CARD_RADIUS = Platform.OS === "ios" ? 53 : 0;
5050export default function DrawerLayout ( ) {
5151 const scheme = useColorScheme ( ) ?? "light" ;
5252 const [ open , setOpen ] = useState ( false ) ;
53- const [ closing , setClosing ] = useState ( false ) ;
53+ const [ transitioningSessionId , setTransitioningSessionId ] = useState <
54+ string | null
55+ > ( null ) ;
5456
55- // `closing` gates the session screen's ChatPanel mount against the close
56- // animation. It is set EAGERLY in closeDrawer (same batch as the `open`
57- // flip and the sidebar's router.replace) so the session being navigated
58- // TO observes it on its very first render — onTransitionStart would land
59- // a frame late, and doesn't fire at all for gesture-settled transitions
60- // (the source skips it when a velocity is passed). Only this programmatic,
61- // navigation-coupled close sets it; gesture/overlay-tap dismissals leave
62- // it false (they keep the current session mounted). openDrawer clears it:
63- // a close reversed back into an open before it lands is no longer closing.
57+ // `transitioningSessionId` gates the session screen's ChatPanel mount against
58+ // the close animation. closeDrawer(toSessionId) records it EAGERLY (same batch
59+ // as the `open` flip and the sidebar's router.replace) so the session being
60+ // navigated TO observes it on its very first render — onTransitionStart would
61+ // land a frame late, and doesn't fire at all for gesture-settled transitions
62+ // (the source skips it when a velocity is passed). Only a programmatic,
63+ // navigation-coupled close passes an id; gesture/overlay-tap dismissals and
64+ // the new-session "+" leave it null (they keep the current session mounted /
65+ // close toward `/`). openDrawer clears it: a close reversed back into an open
66+ // before it lands is no longer transitioning.
6467 //
65- // The `open` guard matters: calling close while already closed must NOT
66- // set `closing` — no animation would run, so no onTransitionEnd would
67- // ever clear it.
68+ // The `open` guard matters: calling close while already closed must NOT set
69+ // the id — no animation would run, so no onTransitionEnd would clear it.
6870 //
69- // Haptics fire at the COMMIT point — the moment `open` actually flips
70- // (tap here, gesture release in onOpen/onClose below) — not at the
71- // drawer's transition events: onTransitionEnd waits for the spring's
72- // mathematical rest (an overdamped sub-pixel tail well past the perceived
73- // landing → buzz felt late), and it also fires for the mount-time
74- // prop-sync toggle (→ spurious buzz on every reload). Gating on the `open`
75- // flip is immediate and skips all echoes.
71+ // Haptics fire at the COMMIT point — the moment `open` actually flips (tap
72+ // here, gesture release in onOpen/onClose below) — not at the drawer's
73+ // transition events: onTransitionEnd waits for the spring's mathematical rest
74+ // (an overdamped sub-pixel tail well past the perceived landing → buzz felt
75+ // late), and it also fires for the mount-time prop-sync toggle (→ spurious
76+ // buzz on every reload). Gating on the `open` flip is immediate and skips all
77+ // echoes.
7678 const openDrawer = useCallback ( ( ) => {
7779 if ( open ) return ;
7880 haptics . drawerToggle ( ) ;
79- setClosing ( false ) ;
81+ setTransitioningSessionId ( null ) ;
8082 setOpen ( true ) ;
8183 } , [ open ] ) ;
8284
83- const closeDrawer = useCallback ( ( ) => {
84- if ( ! open ) return ;
85- haptics . drawerToggle ( ) ;
86- setClosing ( true ) ;
87- setOpen ( false ) ;
88- } , [ open ] ) ;
85+ const closeDrawer = useCallback (
86+ ( toSessionId ?: string ) => {
87+ if ( ! open ) return ;
88+ haptics . drawerToggle ( ) ;
89+ setTransitioningSessionId ( toSessionId ?? null ) ;
90+ setOpen ( false ) ;
91+ } ,
92+ [ open ] ,
93+ ) ;
8994
9095 const drawerUI = useMemo (
91- ( ) => ( { open , closing , openDrawer, closeDrawer } ) ,
92- [ open , closing , openDrawer , closeDrawer ] ,
96+ ( ) => ( { transitioningSessionId , openDrawer, closeDrawer } ) ,
97+ [ transitioningSessionId , openDrawer , closeDrawer ] ,
9398 ) ;
9499
95100 return (
96101 < DrawerUIContext . Provider value = { drawerUI } >
97102 < Drawer
98103 open = { open }
99104 onOpen = { ( ) => {
100- // Gesture-driven open commits here, at release (programmatic
101- // open came through openDrawer where `open` is already true —
102- // and the mount/resync echoes arrive with `open` unchanged —
103- // so the state-change check dedupes all of them). Clear `closing`
104- // too: a swipe that reverses an in-flight close means we're
105- // opening now.
105+ // Gesture-driven open commits here, at release (programmatic open
106+ // came through openDrawer where `open` is already true — and the
107+ // mount/resync echoes arrive with `open` unchanged — so the
108+ // state-change check dedupes all of them). Clear the transitioning id
109+ // too: a swipe that reverses an in-flight close means we're opening now.
106110 if ( ! open ) haptics . drawerToggle ( ) ;
107- setClosing ( false ) ;
111+ setTransitioningSessionId ( null ) ;
108112 setOpen ( true ) ;
109113 } }
110114 onClose = { ( ) => {
111- // Gesture / overlay-tap dismissals land here. They deliberately
112- // do NOT touch `closing` — the current session stays mounted, so
113- // gating it would only flicker ChatPanel back to loading. The
114- // navigation-coupled close already set `closing` in closeDrawer.
115+ // Gesture / overlay-tap dismissals land here. They deliberately do
116+ // NOT set a transitioning id — the current session stays mounted, so
117+ // gating it would only flicker ChatPanel back to loading. A
118+ // navigation-coupled close already recorded its id in closeDrawer.
115119 if ( open ) haptics . drawerToggle ( ) ;
116120 setOpen ( false ) ;
117121 } }
118- onTransitionEnd = { ( closing ) => {
122+ onTransitionEnd = { ( isClosing ) => {
119123 // Only a completed CLOSE clears the gate. Open-end events carry
120124 // closing=false and are ignored — including a stale one delivered
121125 // via runOnJS AFTER a new close was already requested (captured on
122- // device 2026-06-12: a row tapped in the same frame the open
123- // landed let the "open ended" callback clear the close's flag,
124- // releasing ChatPanel mid-close and freezing the drawer
125- // half-open). Acting only on closing=true closes that race with no
126- // direction-match against `open`.
127- if ( closing ) setClosing ( false ) ;
126+ // device 2026-06-12: a row tapped in the same frame the open landed
127+ // let the "open ended" callback clear the gate, releasing ChatPanel
128+ // mid-close and freezing the drawer half-open). Acting only on
129+ // isClosing closes that race. (onTransitionEnd can't tell WHICH
130+ // session finished — it just clears whatever id is pending; for a
131+ // single drawer close that's always the latest target, so correct.)
132+ if ( isClosing ) setTransitioningSessionId ( null ) ;
128133 } }
129134 drawerType = "back"
130135 overlayStyle = { { backgroundColor : "transparent" } }
0 commit comments