Skip to content

Commit 7e2afd2

Browse files
yyq1025claude
andcommitted
app: bare react-native-drawer-layout + drawer-settle ChatPanel gate
- replace the drawer navigator with the bare component; open/transitioning are plain context state (drawer-ui) — no timers, params, or navigation-event plumbing - gate the heavy ChatPanel mount on the close animation's end so the Reanimated transition never competes with native view inflation (TranscriptLoading renders while animating) - direction-matched onTransitionEnd: a stale opposite-direction end (the open spring landing right as a close is requested) can no longer clear the transitioning flag — the captured race released the mount mid-close and froze the drawer half-open - selection haptic at the commit point (the `open` flip), echo-deduped; transition events buzz late (overdamped spring tail) and echo on mount Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3d1bd87 commit 7e2afd2

8 files changed

Lines changed: 234 additions & 94 deletions

File tree

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"react": "19.2.3",
5858
"react-dom": "19.2.3",
5959
"react-native": "0.85.3",
60+
"react-native-drawer-layout": "^4.2.5",
6061
"react-native-enriched-markdown": "0.6.0",
6162
"react-native-gesture-handler": "^2.31.2",
6263
"react-native-get-random-values": "~1.11.0",

packages/app/src/app/(main)/(drawer)/(stack)/index.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { ImageAttachment } from "@sidecodeapp/protocol";
22
import * as Crypto from "expo-crypto";
3-
import { router, Stack, useNavigation } from "expo-router";
4-
import { DrawerActions } from "expo-router/react-navigation";
3+
import { router, Stack } from "expo-router";
54
import { useCallback, useState } from "react";
65
import { Alert, Pressable, Text, View } from "react-native";
76
import {
@@ -12,6 +11,7 @@ import { GitStatusBar } from "@/components/transcript/git-status-bar";
1211
import { InputBar } from "@/components/transcript/input-bar";
1312
import { useFilesystemRoots } from "@/hooks/use-filesystem-roots";
1413
import { useLastUsedCwd, useSetLastUsedCwd } from "@/hooks/use-last-used-cwd";
14+
import { useDrawerUI } from "@/lib/drawer-ui";
1515
import { createSession } from "@/lib/sessions-collection";
1616

1717
/**
@@ -53,7 +53,7 @@ import { createSession } from "@/lib/sessions-collection";
5353
* dodge the cursor race window; the daemon-side seed removes that need.)
5454
*/
5555
export default function NewSessionScreen() {
56-
const navigation = useNavigation();
56+
const { openDrawer } = useDrawerUI();
5757

5858
// Default cwd: client-side "last used" wins; otherwise fall back to
5959
// server's most-recent activity. Both can be undefined on a brand-
@@ -139,10 +139,6 @@ export default function NewSessionScreen() {
139139
[cwd, setLastUsedCwd, createBridged],
140140
);
141141

142-
const openDrawer = useCallback(() => {
143-
navigation.dispatch(DrawerActions.openDrawer());
144-
}, [navigation]);
145-
146142
const openCwdPicker = useCallback(() => {
147143
// Push the cwd-picker modal route. The sheet runs `setLastUsedCwd`
148144
// itself on confirm and pops via `router.dismissTo("/")` — this

packages/app/src/app/(main)/(drawer)/(stack)/session/[cliSessionId].tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { eq, useLiveQuery } from "@tanstack/react-db";
2-
import { Stack, useLocalSearchParams, useNavigation } from "expo-router";
3-
import { DrawerActions } from "expo-router/react-navigation";
4-
import { useCallback, useEffect, useMemo } from "react";
2+
import { Stack, useLocalSearchParams } from "expo-router";
3+
import { useEffect, useMemo, useState } from "react";
54
import { Text, View } from "react-native";
65
import { SessionBridgeToolbar } from "@/components/session-bridge-toolbar";
76
import { ChatPanel } from "@/components/transcript/chat-panel";
87
import { TranscriptLoading } from "@/components/transcript/transcript-loading";
98
import { useSessionTranscript } from "@/hooks/use-session-transcript";
9+
import { useDrawerUI } from "@/lib/drawer-ui";
1010
import { sessionStateCollection } from "@/lib/sessions-collection";
1111
import { flattenToBlocks } from "@/lib/transcript-blocks";
1212

@@ -58,7 +58,26 @@ export default function SessionDetailScreen() {
5858
new?: string;
5959
}>();
6060
const session = useSessionTranscript(cliSessionId, newFlag === "1");
61-
const navigation = useNavigation();
61+
62+
// Drawer-settle gate. Mounting ChatPanel is heavy on the MAIN thread
63+
// (native view inflation + enriched's dispatch_sync measure), which the
64+
// drawer-close animation (Reanimated, UI thread) directly competes with —
65+
// JS fps can look fine while the close visibly stutters. While the drawer
66+
// is animating (sidebar tap → closeDrawer flips `transitioning` in the
67+
// same batch as the route swap), render the cheap TranscriptLoading;
68+
// mount ChatPanel once the animation ends. Keyed by cliSessionId so
69+
// switching sessions through the drawer re-gates each time; entries with
70+
// no drawer animation (new-session replace, deep links) settle on the
71+
// first effect pass. Plain context state — no timers, no params, no
72+
// navigation events (see drawer-ui.tsx).
73+
const { transitioning, openDrawer } = useDrawerUI();
74+
const [settledFor, setSettledFor] = useState<string | null>(
75+
transitioning ? null : cliSessionId,
76+
);
77+
const drawerSettled = settledFor === cliSessionId;
78+
useEffect(() => {
79+
if (!transitioning) setSettledFor(cliSessionId);
80+
}, [transitioning, cliSessionId]);
6281

6382
// Log turn-failure errors. UI is intentionally absent for V0 —
6483
// surfacing assistant errors well needs design work we haven't done
@@ -70,10 +89,6 @@ export default function SessionDetailScreen() {
7089
}
7190
}, [session.lastError]);
7291

73-
const openDrawer = useCallback(() => {
74-
navigation.dispatch(DrawerActions.openDrawer());
75-
}, [navigation]);
76-
7792
const blocks = useMemo(() => flattenToBlocks(session.items), [session.items]);
7893

7994
// Read THIS session's row from the collection with a filtered live query
@@ -111,7 +126,7 @@ export default function SessionDetailScreen() {
111126
{/* ToolCallSheetProvider lives in (stack)/_layout.tsx so its resident
112127
webview + worker pool are shared across session switches. */}
113128
<View className="flex-1 bg-white dark:bg-black">
114-
{session.isInitialLoading ? (
129+
{session.isInitialLoading || !drawerSettled ? (
115130
<TranscriptLoading />
116131
) : session.error ? (
117132
<View className="flex-1 items-center justify-center px-6">
Lines changed: 113 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
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";
35
import { SessionListSidebar } from "@/components/session-list-sidebar";
6+
import { DrawerUIContext } from "@/lib/drawer-ui";
7+
import { haptics } from "@/lib/haptics";
48

59
const 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
*/
3549
export 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
}

packages/app/src/components/session-list-sidebar.tsx

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
2020
import { SessionRow } from "@/components/session-row";
2121
import { useSetLastUsedCwd } from "@/hooks/use-last-used-cwd";
2222
import { useSessions } from "@/hooks/use-sessions";
23+
import { useDrawerUI } from "@/lib/drawer-ui";
2324
import { dayKey, formatDaySection } from "@/lib/format";
2425
import type { SessionRow as SessionRowData } from "@/lib/sessions-collection";
2526

@@ -71,11 +72,8 @@ const HEADER_CONTENT_HEIGHT = 52;
7172
* inner Stack). Tap the header gear → router.push("/settings") → root Stack
7273
* pushes the modal sheet over (drawer).
7374
*
74-
* Drawer's `navigation` prop only needs `closeDrawer`; full
75-
* `DrawerContentComponentProps` shape lives at
76-
* `node_modules/expo-router/build/react-navigation/drawer/types.d.ts:178` but
77-
* isn't re-exported from a clean public path — typing inline keeps the import
78-
* surface small.
75+
* Drawer control comes from `useDrawerUI()` (the bare-drawer-layout state
76+
* owned by `(drawer)/_layout.tsx`) — no navigator props.
7977
*
8078
* Header chrome: a plain absolute View floats over the list with a
8179
* linear-gradient scrim (`experimental_backgroundImage`) + the brand title and
@@ -95,17 +93,10 @@ const HEADER_CONTENT_HEIGHT = 52;
9593
* as flat — so we keep this simple gradient. See memory
9694
* `project_sidebar_scrolledge_spike`.
9795
*/
98-
interface SidebarNavigation {
99-
closeDrawer: () => void;
100-
}
101-
102-
export function SessionListSidebar({
103-
navigation,
104-
}: {
105-
navigation: SidebarNavigation;
106-
}) {
96+
export function SessionListSidebar() {
10797
const insets = useSafeAreaInsets();
10898
const scheme = useColorScheme() ?? "light";
99+
const { closeDrawer } = useDrawerUI();
109100

110101
// Full floating-header band = status-bar inset + content height. Shared with
111102
// the list's contentContainerStyle.paddingTop (see Body) so row 1 starts
@@ -143,33 +134,29 @@ export function SessionListSidebar({
143134
// the project the user just opened. Mutation is fire-and-forget;
144135
// the navigation below shouldn't wait on SecureStore I/O.
145136
setLastUsedCwd.mutate(session.cwd);
146-
navigation.closeDrawer();
147-
// Defer the route swap one frame so the drawer-close animation kicks off on
148-
// clean frames first — closing before the swap protects the animation's
149-
// startup, since the destination's heavy first render (ChatPanel +
150-
// LegendList bootstrap + transcript) is JS-thread work that would otherwise
151-
// share the frame closeDrawer dispatches on. (requestAnimationFrame, not the
152-
// deprecated InteractionManager.)
153-
requestAnimationFrame(() => {
154-
router.replace({
155-
pathname: "/session/[cliSessionId]",
156-
params: {
157-
cliSessionId: session.cliSessionId,
158-
// No title param — the detail screen reads it from the session's
159-
// collection row (filtered live query), so it stays correct as
160-
// the daemon's canonical title lands.
161-
//
162-
// Pass cwd so the session screen can hand it to sendPrompt — the
163-
// SDK derives the project key from cwd to locate the JSONL for
164-
// `claude --resume`.
165-
cwd: session.cwd,
166-
},
167-
});
137+
// closeDrawer flips `transitioning` in the same batch, so the route
138+
// swap below mounts only the cheap TranscriptLoading — the session
139+
// screen's drawer-settle gate holds the heavy ChatPanel mount until
140+
// the close animation finishes. No frame-deferral needed.
141+
closeDrawer();
142+
router.replace({
143+
pathname: "/session/[cliSessionId]",
144+
params: {
145+
cliSessionId: session.cliSessionId,
146+
// No title param — the detail screen reads it from the session's
147+
// collection row (filtered live query), so it stays correct as
148+
// the daemon's canonical title lands.
149+
//
150+
// Pass cwd so the session screen can hand it to sendPrompt — the
151+
// SDK derives the project key from cwd to locate the JSONL for
152+
// `claude --resume`.
153+
cwd: session.cwd,
154+
},
168155
});
169156
};
170157

171158
const handleNewSession = () => {
172-
navigation.closeDrawer();
159+
closeDrawer();
173160
// Replace, not push: the new-session page is a sibling of the detail
174161
// inside the same inner Stack ((drawer)/(stack)/index → URL "/"), so
175162
// switching to it from a detail screen is a top-of-Stack swap (no

0 commit comments

Comments
 (0)