1- import { Drawer } from "expo-router/drawer" ;
2- import { Platform , useColorScheme } from "react-native" ;
1+ import { Slot } from "expo-router" ;
2+ import { useCallback , useMemo , useState } from "react" ;
3+ import { Platform , useColorScheme , View } from "react-native" ;
4+ import { Drawer } from "react-native-drawer-layout" ;
35import { SessionListSidebar } from "@/components/session-list-sidebar" ;
6+ import { DrawerUIContext } from "@/lib/drawer-ui" ;
7+ import { haptics } from "@/lib/haptics" ;
48
59const CARD_RADIUS = Platform . OS === "ios" ? 53 : 0 ;
610
711/**
8- * Drawer layout — wraps the session screens with a sidebar (custom
9- * drawerContent rendering the session list + user pill). Modeled on Claude
10- * iOS / ChatGPT iOS: tap a session in sidebar → switch active screen,
11- * tap "+" → switch back to /session (the new-session create page).
12+ * Drawer layout — wraps the session screens with a sidebar (the session
13+ * list + user pill). Modeled on Claude iOS / ChatGPT iOS: tap a session in
14+ * sidebar → switch active screen, tap "+" → switch back to /session (the
15+ * new-session create page).
16+ *
17+ * 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 plain React state + direct
23+ * onTransitionStart/End props, the gate is exact. Visuals are identical —
24+ * the navigator was a thin wrapper around this same component.
1225 *
1326 * Sole child is the `(stack)` group (see ./(stack)/_layout.tsx) — its
1427 * inner Stack contains both the new-session page (`index` → URL `/`) and
@@ -18,11 +31,12 @@ const CARD_RADIUS = Platform.OS === "ios" ? 53 : 0;
1831 * new-session page) directly without any redirect.
1932 *
2033 * `drawerType: "back"` — the drawer sits behind and the main content
21- * slides right to reveal it. Combined with `sceneStyle` (borderRadius +
22- * boxShadow) the main content reads as a rounded card floating over the
23- * sidebar, à la chat-template. The drawer width controls how much card
24- * peek remains; at "100%" the card slides fully off-screen when open
25- * (no peek), so use < 100% for the persistent-card look.
34+ * slides right to reveal it. Combined with the rounded-card wrapper View
35+ * (borderRadius + boxShadow — the former navigator `sceneStyle`) the main
36+ * content reads as a card floating over the sidebar, à la chat-template.
37+ * The drawer width controls how much card peek remains; at "100%" the
38+ * card slides fully off-screen when open (no peek), so use < 100% for the
39+ * persistent-card look.
2640 *
2741 * `swipeEdgeWidth: 120` — leftmost 120pt is swipe-to-open hot zone. Wide
2842 * enough to feel responsive but bounded so it doesn't fight horizontal-
@@ -34,32 +48,96 @@ const CARD_RADIUS = Platform.OS === "ios" ? 53 : 0;
3448 */
3549export default function DrawerLayout ( ) {
3650 const scheme = useColorScheme ( ) ?? "light" ;
51+ const [ open , setOpen ] = useState ( false ) ;
52+ const [ transitioning , setTransitioning ] = useState ( false ) ;
53+
54+ // The `open` guard matters: calling close while already closed must NOT
55+ // set `transitioning` — no animation would run, so no onTransitionEnd
56+ // would ever clear it. Setting `transitioning` EAGERLY (same batch as
57+ // the open flip) is the gate's real mechanism: the component's own
58+ // onTransitionStart lands a frame later AND doesn't fire at all for
59+ // gesture-settled transitions (the source skips it when a velocity is
60+ // passed), while the session screen must see the flag in the same batch
61+ // as the route change that follows a sidebar tap.
62+ // Haptics fire at the COMMIT point — the moment `open` actually flips
63+ // (tap here, gesture release in onOpen/onClose below) — not at the
64+ // drawer's transition events: onTransitionEnd waits for the spring's
65+ // mathematical rest (an overdamped sub-pixel tail well past the
66+ // perceived landing → buzz felt late), and both transition events also
67+ // fire for the mount-time prop-sync toggle (→ spurious buzz on every
68+ // reload). Gating on the `open` flip is immediate and skips all echoes.
69+ const openDrawer = useCallback ( ( ) => {
70+ if ( open ) return ;
71+ haptics . drawerToggle ( ) ;
72+ setTransitioning ( true ) ;
73+ setOpen ( true ) ;
74+ } , [ open ] ) ;
75+
76+ const closeDrawer = useCallback ( ( ) => {
77+ if ( ! open ) return ;
78+ haptics . drawerToggle ( ) ;
79+ setTransitioning ( true ) ;
80+ setOpen ( false ) ;
81+ } , [ open ] ) ;
82+
83+ const drawerUI = useMemo (
84+ ( ) => ( { open, transitioning, openDrawer, closeDrawer } ) ,
85+ [ open , transitioning , openDrawer , closeDrawer ] ,
86+ ) ;
87+
3788 return (
38- < Drawer
39- detachInactiveScreens = { false }
40- drawerContent = { ( props ) => < SessionListSidebar { ...props } /> }
41- screenOptions = { {
42- drawerType : "back" ,
43- overlayColor : "transparent" ,
44- sceneStyle : {
45- borderRadius : CARD_RADIUS ,
46- borderCurve : "continuous" ,
47- overflow : "hidden" ,
48- boxShadow : "0px 0px 16px rgba(0,0,0,0.15)" ,
49- } ,
50- drawerStyle : {
89+ < DrawerUIContext . Provider value = { drawerUI } >
90+ < Drawer
91+ open = { open }
92+ onOpen = { ( ) => {
93+ // Gesture-driven open commits here, at release (programmatic
94+ // open came through openDrawer where `open` is already true —
95+ // and the mount/resync echoes arrive with `open` unchanged —
96+ // so the state-change check dedupes all of them).
97+ if ( ! open ) haptics . drawerToggle ( ) ;
98+ setOpen ( true ) ;
99+ } }
100+ onClose = { ( ) => {
101+ if ( open ) haptics . drawerToggle ( ) ;
102+ setOpen ( false ) ;
103+ } }
104+ onTransitionStart = { ( ) => setTransitioning ( true ) }
105+ onTransitionEnd = { ( closing ) => {
106+ // Direction match — only the end event whose direction agrees
107+ // with the current target state may clear `transitioning`. A
108+ // spring that completes (finished=true) right as the OPPOSITE
109+ // transition is requested delivers its end callback via runOnJS
110+ // AFTER the new eager setTransitioning(true) — captured on
111+ // device 2026-06-12: tapping a session row in the same frame the
112+ // open animation landed let the stale "open ended" event clear
113+ // the close transition's flag; the gate then released ChatPanel
114+ // mid-close and the mount choke froze the drawer half-open. A
115+ // stale end is always opposite-direction (that's what makes it
116+ // stale), so the check closes the race exactly.
117+ if ( closing !== ! open ) return ;
118+ setTransitioning ( false ) ;
119+ } }
120+ drawerType = "back"
121+ overlayStyle = { { backgroundColor : "transparent" } }
122+ drawerStyle = { {
51123 width : "80%" ,
52124 backgroundColor : scheme === "dark" ? "#000000" : "#ffffff" ,
53- } ,
54- swipeEdgeWidth : 120 ,
55- headerShown : false ,
56- } }
57- >
58- { /* `(stack)` is a nested Stack group (see ./(stack)/_layout.tsx) —
59- register the group as a single Drawer screen, not the inner
60- leaves. The leaves are mounted by the inner Stack and inherit
61- this screen's drawer placement. */ }
62- < Drawer . Screen name = "(stack)" />
63- </ Drawer >
125+ } }
126+ swipeEdgeWidth = { 120 }
127+ renderDrawerContent = { ( ) => < SessionListSidebar /> }
128+ >
129+ < View
130+ style = { {
131+ flex : 1 ,
132+ borderRadius : CARD_RADIUS ,
133+ borderCurve : "continuous" ,
134+ overflow : "hidden" ,
135+ boxShadow : "0px 0px 16px rgba(0,0,0,0.15)" ,
136+ } }
137+ >
138+ < Slot />
139+ </ View >
140+ </ Drawer >
141+ </ DrawerUIContext . Provider >
64142 ) ;
65143}
0 commit comments