Skip to content

Commit c75b3f3

Browse files
yyq1025claude
andcommitted
drawer: gate the ChatPanel mount on the transitioning session id
Replace the boolean `closing` flag with `transitioningSessionId: string | null`. closeDrawer(toSessionId) records which session a navigation-coupled close is animating toward; the detail screen gates its heavy ChatPanel mount only while it IS that id (so just the target session shows the cheap loading placeholder during the close), and onTransitionEnd clears it. Add a same-session no-op guard: tapping the already-open row in the sidebar just closes the drawer instead of router.replace-ing to the same path-param route (which mints a fresh route key -> remount + Loading flash). Active id read via useGlobalSearchParams in the sidebar parent. Drop the now-unused `open` field from the DrawerUI context (the layout keeps it as local state for the <Drawer open> prop). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 44fbdd0 commit c75b3f3

4 files changed

Lines changed: 124 additions & 93 deletions

File tree

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

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,21 @@ export default function SessionDetailScreen() {
5959
}>();
6060
const session = useSessionTranscript(cliSessionId, newFlag === "1");
6161

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. A sidebar tap
66-
// flips `closing` (in closeDrawer) in the same batch as the route swap, so
67-
// the session being navigated TO mounts with `closing === true` and shows
68-
// the cheap TranscriptLoading; ChatPanel mounts once the close lands
69-
// (onTransitionEnd clears `closing`). Every other entry path — deep link,
62+
// Drawer-settle gate. Mounting ChatPanel is heavy on the MAIN thread (native
63+
// view inflation + enriched's dispatch_sync measure), which the drawer-close
64+
// animation (Reanimated, UI thread) directly competes with — JS fps can look
65+
// fine while the close visibly stutters. A sidebar tap records THIS session's
66+
// id as the drawer's `transitioningSessionId` (in closeDrawer) in the same
67+
// batch as the route swap, so the session being navigated TO mounts gated and
68+
// shows the cheap TranscriptLoading; ChatPanel mounts once the close lands
69+
// (onTransitionEnd clears the id). Every other entry path — deep link,
7070
// new-session replace, the drawer merely opening over an already-settled
71-
// session — has `closing === false`. Because `closing` is scoped to
72-
// navigation-coupled closes (see drawer-ui.tsx) it never flips true under a
73-
// mounted session, so the gate is just a derived boolean: no state, no
74-
// effect, no per-session keying.
75-
const { closing, openDrawer } = useDrawerUI();
76-
const drawerSettled = !closing;
71+
// session — has `transitioningSessionId !== cliSessionId`. Carrying the id
72+
// (vs a bare `closing` boolean) means only the target session gates, and it
73+
// lets the sidebar no-op a tap on the already-open session (handleOpenSession).
74+
// The gate is a derived boolean: no state, no effect, no per-session keying.
75+
const { transitioningSessionId, openDrawer } = useDrawerUI();
76+
const drawerSettled = transitioningSessionId !== cliSessionId;
7777

7878
// Log turn-failure errors. UI is intentionally absent for V0 —
7979
// surfacing assistant errors well needs design work we haven't done

packages/app/src/app/(main)/(drawer)/_layout.tsx

Lines changed: 60 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
5050
export 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" }}

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

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ export function SessionListSidebar() {
9797
const insets = useSafeAreaInsets();
9898
const scheme = useColorScheme() ?? "light";
9999
const { closeDrawer } = useDrawerUI();
100+
// Active session id (the focused detail route's path param) — lets
101+
// handleOpenSession no-op a tap on the already-open session. useGlobalSearch-
102+
// Params (not Local: the sidebar isn't a route screen) re-renders this on
103+
// nav, but the parent already re-renders on every drawer toggle via
104+
// useDrawerUI, so on the drawer-switch path it costs nothing extra.
105+
const { cliSessionId: activeSessionId } = useGlobalSearchParams<{
106+
cliSessionId?: string;
107+
}>();
100108

101109
// Full floating-header band = status-bar inset + content height. Shared with
102110
// the list's contentContainerStyle.paddingTop (see Body) so row 1 starts
@@ -130,15 +138,23 @@ export function SessionListSidebar() {
130138
// they don't expect to "go back" to.
131139
const setLastUsedCwd = useSetLastUsedCwd();
132140
const handleOpenSession = (session: SessionRowData) => {
141+
// Tapping the already-open session: just close the drawer. router.replace
142+
// to the same path-param route still mints a fresh route key → remounts
143+
// ChatPanel + flashes Loading, for no actual navigation. Skip it.
144+
if (session.cliSessionId === activeSessionId) {
145+
closeDrawer();
146+
return;
147+
}
133148
// Record the current focus so the next "New session" defaults to
134149
// the project the user just opened. Mutation is fire-and-forget;
135150
// the navigation below shouldn't wait on SecureStore I/O.
136151
setLastUsedCwd.mutate(session.cwd);
137-
// closeDrawer flips `closing` in the same batch, so the route swap
138-
// below mounts only the cheap TranscriptLoading — the session screen's
139-
// drawer-settle gate holds the heavy ChatPanel mount until the close
140-
// animation finishes. No frame-deferral needed.
141-
closeDrawer();
152+
// closeDrawer(targetId) records the gate target in the same batch as the
153+
// route swap below, so the session navigated TO mounts the cheap
154+
// TranscriptLoading first — the session screen's drawer-settle gate holds
155+
// the heavy ChatPanel mount until the close animation finishes. No
156+
// frame-deferral needed.
157+
closeDrawer(session.cliSessionId);
142158
router.replace({
143159
pathname: "/session/[cliSessionId]",
144160
params: {

packages/app/src/lib/drawer-ui.tsx

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,40 @@ import { createContext, useContext } from "react";
22

33
/**
44
* UI state for the session-list drawer (bare react-native-drawer-layout —
5-
* deliberately NOT a drawer navigator: the drawer hosts no routes, and
6-
* owning `open`/`closing` as plain React state is what lets the session
5+
* deliberately NOT a drawer navigator: the drawer hosts no routes, and owning
6+
* `open`/`transitioningSessionId` as plain React state is what lets the session
77
* screen gate its heavy ChatPanel mount on the close animation with zero
8-
* timers, params, or navigation-event plumbing. Provided by the (drawer)
9-
* group layout.
8+
* timers, params, or navigation-event plumbing. Provided by the (drawer) group
9+
* layout.
1010
*
11-
* `closing` is true only while a programmatic, navigation-coupled close is
12-
* animating — i.e. the close `closeDrawer` fires from a sidebar tap, paired
13-
* in the same batch with the `router.replace` to the new session. It is set
14-
* eagerly in `closeDrawer` (so the screen being navigated TO sees it on its
15-
* first render) and cleared on the matching onTransitionEnd, or earlier if
16-
* the close reverses back into an open (openDrawer/onOpen). It is
17-
* deliberately NOT set for gesture / overlay-tap dismissals or for open
18-
* transitions: those keep the current session mounted, so a plain
19-
* `!closing` is all the gate needs (see the session screen). Tracking only
20-
* the close direction also dissolves the old stale-end race — an open-end
21-
* callback carries closing=false and is simply ignored.
11+
* The context exposes the imperative controls (`openDrawer` from the session +
12+
* new-session hamburger, `closeDrawer` from the sidebar) plus
13+
* `transitioningSessionId`. `open` itself stays local to the layout (it drives
14+
* the `<Drawer open>` prop); nothing outside reads it.
15+
*
16+
* `transitioningSessionId` names the session a navigation-coupled close is
17+
* animating TOWARD: `closeDrawer(toSessionId)` fires from a sidebar tap, in the
18+
* same batch as the `router.replace` to that session, and records the id here
19+
* (so the session being navigated TO sees it on its first render). The detail
20+
* screen gates its ChatPanel mount while this equals its own id, then
21+
* onTransitionEnd clears it to null — or openDrawer/onOpen clears it if the
22+
* close reverses into an open. It is deliberately NOT set for gesture /
23+
* overlay-tap dismissals or for the new-session "+" (which closes toward `/`,
24+
* not a session): those pass no id, so every mounted session has
25+
* `transitioningSessionId !== its id` and isn't gated. Carrying the id (vs a
26+
* bare `closing` boolean) also lets the sidebar skip the whole dance when you
27+
* tap the already-open session — see handleOpenSession.
2228
*/
2329
export interface DrawerUI {
24-
open: boolean;
25-
/** A navigation-coupled drawer-close animation is in flight. */
26-
closing: boolean;
30+
/** The session a navigation-coupled drawer-close is animating toward, or null
31+
* when none is in flight. The detail screen gates its ChatPanel mount while
32+
* this equals its own id. */
33+
transitioningSessionId: string | null;
2734
openDrawer: () => void;
28-
closeDrawer: () => void;
35+
/** Close the drawer. `toSessionId` records which session is being navigated
36+
* to so its detail screen can gate its mount until the close lands; omit it
37+
* for gesture dismissals and the new-session "+" (closes toward `/`). */
38+
closeDrawer: (toSessionId?: string) => void;
2939
}
3040

3141
export const DrawerUIContext = createContext<DrawerUI | null>(null);

0 commit comments

Comments
 (0)