Keep settled mobile sidebars hidden under UI load

The earlier ghost-open fix gated re-commits on a settle counter, but on
Fabric that doesn't hold: Reanimated re-applies its own (stale) animated
props over React's committed props on every commit, so a settled-closed
sidebar still reappears after a heavy commit (e.g. a long thread mounting
or a message send) — reanimated#9635. No committed transform/opacity
value can win while a useAnimatedStyle is attached.

Split ownership instead: React owns whether the overlay exists, the
worklet owns its motion. display lives on the plain wrapper View that
Reanimated never touches, so a hidden overlay stays hidden regardless of
what the animated props revert to. An open gesture flips an overlayPeek
flag to reveal the panel before the store updates; a short close grace
keeps it displayed until the close animation finishes.

Residual: a heavy commit landing mid-close-animation can still flash the
panel until the animation settles — transient, not the previous permanent
wedge.
This commit is contained in:
Mohamed Boudra
2026-06-13 08:34:52 +07:00
parent 04a985bf23
commit e0463ad17f
6 changed files with 112 additions and 132 deletions
+5
View File
@@ -511,6 +511,7 @@ function MobileGestureWrapper({
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
setOverlayPeek,
isGesturing, isGesturing,
mobilePanelState, mobilePanelState,
gestureAnimatingRef, gestureAnimatingRef,
@@ -579,6 +580,8 @@ function MobileGestureWrapper({
}) })
.onStart(() => { .onStart(() => {
isGesturing.value = true; isGesturing.value = true;
// The overlay is display:none while closed; reveal it for the drag.
runOnJS(setOverlayPeek)(true);
}) })
.onUpdate((event) => { .onUpdate((event) => {
const newTranslateX = Math.min(0, -windowWidth + event.translationX); const newTranslateX = Math.min(0, -windowWidth + event.translationX);
@@ -602,6 +605,7 @@ function MobileGestureWrapper({
}) })
.onFinalize(() => { .onFinalize(() => {
isGesturing.value = false; isGesturing.value = false;
runOnJS(setOverlayPeek)(false);
}), }),
[ [
openGestureEnabled, openGestureEnabled,
@@ -611,6 +615,7 @@ function MobileGestureWrapper({
mobilePanelState, mobilePanelState,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
setOverlayPeek,
handleGestureOpen, handleGestureOpen,
isGesturing, isGesturing,
openGestureRef, openGestureRef,
@@ -97,7 +97,7 @@ export function ExplorerSidebar({
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
settledGeneration, overlayVisible,
isGesturing, isGesturing,
gestureAnimatingRef, gestureAnimatingRef,
closeGestureRef, closeGestureRef,
@@ -260,25 +260,13 @@ export function ExplorerSidebar({
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
); );
// settledGeneration as deps: after each settle the updater is rebuilt so the const sidebarAnimatedStyle = useAnimatedStyle(() => ({
// shared values are re-applied to the view, protecting against a heavy Fabric transform: [{ translateX: translateX.value }],
// commit reverting the transform to stale React-committed props (#9635). }));
const sidebarAnimatedStyle = useAnimatedStyle(
() => ({
transform: [{ translateX: translateX.value }],
}),
[settledGeneration],
);
// pointerEvents comes from React state, not the worklet: the Fabric revert const backdropAnimatedStyle = useAnimatedStyle(() => ({
// protection above does not cover pointerEvents, so a worklet-driven value opacity: backdropOpacity.value,
// can wedge an invisible tap-eating backdrop after a heavy commit. }));
const backdropAnimatedStyle = useAnimatedStyle(
() => ({
opacity: backdropOpacity.value,
}),
[settledGeneration],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({ const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value, width: resizeWidth.value,
@@ -288,6 +276,9 @@ export function ExplorerSidebar({
() => [ () => [
explorerStaticStyles.backdrop, explorerStaticStyles.backdrop,
backdropAnimatedStyle, backdropAnimatedStyle,
// pointerEvents is React-owned, not worklet-owned: Reanimated never
// touches it, so a stale animated-prop revert can't wedge an invisible
// tap-eating backdrop.
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) }, { pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
], ],
[backdropAnimatedStyle, isOpen], [backdropAnimatedStyle, isOpen],
@@ -311,6 +302,16 @@ export function ExplorerSidebar({
mobileKeyboardInsetStyle, mobileKeyboardInsetStyle,
], ],
); );
// display is React-owned on the plain wrapper View (no animated styles), so
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
// reverts the panel transform to after a heavy commit (reanimated#9635).
const overlayStyle = useMemo(
() => [
StyleSheet.absoluteFillObject,
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
],
[overlayVisible],
);
const desktopSidebarStyle = useMemo( const desktopSidebarStyle = useMemo(
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }], () => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
[resizeAnimatedStyle, insets.top], [resizeAnimatedStyle, insets.top],
@@ -331,7 +332,7 @@ export function ExplorerSidebar({
if (isMobile) { if (isMobile) {
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <View style={overlayStyle} pointerEvents={overlayPointerEvents}>
{/* Backdrop */} {/* Backdrop */}
<Animated.View style={backdropCombinedStyle} /> <Animated.View style={backdropCombinedStyle} />
+22 -24
View File
@@ -41,10 +41,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
useSidebarAnimation,
useSidebarSettledGeneration,
} from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -618,12 +615,12 @@ function MobileSidebar({
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
overlayVisible,
isGesturing, isGesturing,
mobilePanelState, mobilePanelState,
gestureAnimatingRef, gestureAnimatingRef,
closeGestureRef, closeGestureRef,
} = useSidebarAnimation(); } = useSidebarAnimation();
const settledGeneration = useSidebarSettledGeneration();
const closeTouchStartX = useSharedValue(0); const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0); const closeTouchStartY = useSharedValue(0);
@@ -752,25 +749,13 @@ function MobileSidebar({
[activeHostStatusColor], [activeHostStatusColor],
); );
// settledGeneration as deps: after each settle the updater is rebuilt so the const sidebarAnimatedStyle = useAnimatedStyle(() => ({
// shared values are re-applied to the view, protecting against a heavy Fabric transform: [{ translateX: translateX.value }],
// commit reverting the transform to stale React-committed props (#9635). }));
const sidebarAnimatedStyle = useAnimatedStyle(
() => ({
transform: [{ translateX: translateX.value }],
}),
[settledGeneration],
);
// pointerEvents comes from React state, not the worklet: the Fabric revert const backdropAnimatedStyle = useAnimatedStyle(() => ({
// protection above does not cover pointerEvents, so a worklet-driven value opacity: backdropOpacity.value,
// can wedge an invisible tap-eating backdrop after a heavy commit. }));
const backdropAnimatedStyle = useAnimatedStyle(
() => ({
opacity: backdropOpacity.value,
}),
[settledGeneration],
);
let overlayPointerEvents: "auto" | "none" | "box-none"; let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none"; if (!isWeb) overlayPointerEvents = "box-none";
@@ -781,6 +766,9 @@ function MobileSidebar({
() => [ () => [
staticStyles.backdrop, staticStyles.backdrop,
backdropAnimatedStyle, backdropAnimatedStyle,
// pointerEvents is React-owned, not worklet-owned: Reanimated never
// touches it, so a stale animated-prop revert can't wedge an invisible
// tap-eating backdrop.
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) }, { pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
], ],
[backdropAnimatedStyle, isOpen], [backdropAnimatedStyle, isOpen],
@@ -794,9 +782,19 @@ function MobileSidebar({
], ],
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar], [mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
); );
// display is React-owned on the plain wrapper View (no animated styles), so
// a hidden overlay stays hidden no matter what Reanimated's Fabric overlay
// reverts the panel transform to after a heavy commit (reanimated#9635).
const overlayStyle = useMemo(
() => [
StyleSheet.absoluteFillObject,
{ display: overlayVisible ? ("flex" as const) : ("none" as const) },
],
[overlayVisible],
);
return ( return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}> <View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View style={backdropStyle} /> <Animated.View style={backdropStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y"> <GestureDetector gesture={closeGesture} touchAction="pan-y">
@@ -9,13 +9,7 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useWindowDimensions } from "react-native"; import { useWindowDimensions } from "react-native";
import { import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
runOnJS,
useSharedValue,
withTiming,
Easing,
type SharedValue,
} from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
@@ -27,13 +21,17 @@ import {
const ANIMATION_DURATION = 220; const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
// Keeps the overlay displayed long enough for the close animation to finish
// before React hides it.
const OVERLAY_CLOSE_GRACE_MS = 300;
interface ExplorerSidebarAnimationContextValue { interface ExplorerSidebarAnimationContextValue {
translateX: SharedValue<number>; translateX: SharedValue<number>;
backdropOpacity: SharedValue<number>; backdropOpacity: SharedValue<number>;
windowWidth: number; windowWidth: number;
animateToOpen: () => void; animateToOpen: () => void;
animateToClose: () => void; animateToClose: () => void;
settledGeneration: number; overlayVisible: boolean;
setOverlayPeek: (peek: boolean) => void;
isGesturing: SharedValue<boolean>; isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>; gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>; openGestureRef: React.MutableRefObject<GestureType | undefined>;
@@ -62,12 +60,21 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const openGestureRef = useRef<GestureType | undefined>(undefined); const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined); const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Same Fabric stale-props revert protection as in sidebar-animation-context: // React owns whether the overlay is displayed at all; the worklet owns its
// bump after every settle so consumers re-commit the settled shared values. // motion. See the matching block in sidebar-animation-context.tsx for why
const [settledGeneration, setSettledGeneration] = useState(0); // (Fabric re-applies stale animated props over committed props,
const bumpSettledGeneration = useCallback(() => { // reanimated#9635).
setSettledGeneration((generation) => generation + 1); const [overlayPeek, setOverlayPeek] = useState(false);
}, []); const overlayTarget = isOpen || overlayPeek;
const [overlayVisible, setOverlayVisible] = useState(overlayTarget);
useEffect(() => {
if (overlayTarget) {
setOverlayVisible(true);
return;
}
const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS);
return () => clearTimeout(timer);
}, [overlayTarget]);
// Track previous isOpen to detect changes // Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen); const prevIsOpen = useRef(isOpen);
@@ -123,7 +130,6 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout) { if (isCompactLayout) {
settleMobilePanel("file-explorer"); settleMobilePanel("file-explorer");
} }
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(targets.backdropOpacity, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -147,7 +153,6 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout && mobileView === "agent") { if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent"); settleMobilePanel("agent");
} }
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(targets.backdropOpacity, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -162,7 +167,6 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout && ownsMobileViewChange) { if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView); settleMobilePanel(mobileView);
} }
bumpSettledGeneration();
}, [ }, [
isOpen, isOpen,
mobileView, mobileView,
@@ -173,7 +177,6 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
isCompactLayout, isCompactLayout,
startMobilePanelTransition, startMobilePanelTransition,
settleMobilePanel, settleMobilePanel,
bumpSettledGeneration,
]); ]);
const animateToOpen = useCallback(() => { const animateToOpen = useCallback(() => {
@@ -188,20 +191,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
(finished) => { (finished) => {
if (!finished) return; if (!finished) return;
settleMobilePanel("file-explorer"); settleMobilePanel("file-explorer");
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(1, { backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}, [ }, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
translateX,
backdropOpacity,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const animateToClose = useCallback(() => { const animateToClose = useCallback(() => {
"worklet"; "worklet";
@@ -215,21 +211,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
(finished) => { (finished) => {
if (!finished) return; if (!finished) return;
settleMobilePanel("agent"); settleMobilePanel("agent");
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(0, { backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}, [ }, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
translateX,
backdropOpacity,
windowWidth,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const value = useMemo<ExplorerSidebarAnimationContextValue>( const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({ () => ({
@@ -238,7 +226,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
settledGeneration, overlayVisible,
setOverlayPeek,
isGesturing, isGesturing,
gestureAnimatingRef, gestureAnimatingRef,
openGestureRef, openGestureRef,
@@ -250,7 +239,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
settledGeneration, overlayVisible,
isGesturing, isGesturing,
], ],
); );
@@ -9,13 +9,7 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { Keyboard, useWindowDimensions } from "react-native"; import { Keyboard, useWindowDimensions } from "react-native";
import { import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
runOnJS,
useSharedValue,
withTiming,
Easing,
type SharedValue,
} from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler"; import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
@@ -38,6 +32,9 @@ import {
const ANIMATION_DURATION = 220; const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
// Keeps the overlay displayed long enough for the close animation to finish
// before React hides it.
const OVERLAY_CLOSE_GRACE_MS = 300;
export const MOBILE_VISUAL_PANEL_AGENT = 0; export const MOBILE_VISUAL_PANEL_AGENT = 0;
export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1; export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1;
export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2; export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2;
@@ -50,6 +47,8 @@ interface SidebarAnimationContextValue {
animateToClose: () => void; animateToClose: () => void;
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void; startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void; settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
overlayVisible: boolean;
setOverlayPeek: (peek: boolean) => void;
isGesturing: SharedValue<boolean>; isGesturing: SharedValue<boolean>;
mobileVisualPanel: SharedValue<number>; mobileVisualPanel: SharedValue<number>;
mobilePanelState: SharedValue<number>; mobilePanelState: SharedValue<number>;
@@ -60,10 +59,6 @@ interface SidebarAnimationContextValue {
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null); const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
// Separate context so that settle-driven re-renders only reach MobileSidebar,
// not every other useSidebarAnimation() consumer.
const SidebarSettledGenerationContext = createContext<number>(0);
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number { function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") { if (mobileView === "agent-list") {
return MOBILE_VISUAL_PANEL_AGENT_LIST; return MOBILE_VISUAL_PANEL_AGENT_LIST;
@@ -104,16 +99,27 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const openGestureRef = useRef<GestureType | undefined>(undefined); const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined); const closeGestureRef = useRef<GestureType | undefined>(undefined);
// After an open/close settles, a heavy Fabric commit can re-apply React's // React owns whether the overlay is displayed at all; the worklet owns its
// stale committed props onto the native view, reverting the UI-thread // motion. On Fabric, Reanimated re-applies its own (possibly stale) animated
// transform (reanimated#9635 — sidebar reappears "ghost-open"). Bumping this // props over React's committed props on every commit, so a settled-closed
// counter after every settle re-renders the consumers so the animated styles // overlay can ghost back on screen after a heavy commit (reanimated#9635) —
// refresh React's committed props from the settled shared values. It must // no committed transform/opacity value can prevent that. display lives on
// never write a shared value — it only triggers a React re-commit. // the plain wrapper View that Reanimated never touches, so React's value is
const [settledGeneration, setSettledGeneration] = useState(0); // authoritative: a hidden overlay stays hidden no matter what the animated
const bumpSettledGeneration = useCallback(() => { // props revert to. overlayPeek shows the overlay during an open gesture
setSettledGeneration((generation) => generation + 1); // before the store flips; the close grace keeps it displayed while the
}, []); // close animation plays.
const [overlayPeek, setOverlayPeek] = useState(false);
const overlayTarget = isOpen || overlayPeek;
const [overlayVisible, setOverlayVisible] = useState(overlayTarget);
useEffect(() => {
if (overlayTarget) {
setOverlayVisible(true);
return;
}
const timer = setTimeout(() => setOverlayVisible(false), OVERLAY_CLOSE_GRACE_MS);
return () => clearTimeout(timer);
}, [overlayTarget]);
// Track previous isOpen to detect changes // Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen); const prevIsOpen = useRef(isOpen);
@@ -241,7 +247,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout) { if (isCompactLayout) {
settleMobilePanel("agent-list"); settleMobilePanel("agent-list");
} }
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(targets.backdropOpacity, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -265,7 +270,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout && mobileView === "agent") { if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent"); settleMobilePanel("agent");
} }
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(targets.backdropOpacity, { backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -280,7 +284,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout && ownsMobileViewChange) { if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView); settleMobilePanel(mobileView);
} }
bumpSettledGeneration();
}, [ }, [
isOpen, isOpen,
mobileView, mobileView,
@@ -293,7 +296,6 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
mobilePanelState, mobilePanelState,
startMobilePanelTransition, startMobilePanelTransition,
settleMobilePanel, settleMobilePanel,
bumpSettledGeneration,
]); ]);
const animateToOpen = useCallback(() => { const animateToOpen = useCallback(() => {
@@ -308,20 +310,13 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
(finished) => { (finished) => {
if (!finished) return; if (!finished) return;
settleMobilePanel("agent-list"); settleMobilePanel("agent-list");
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(1, { backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}, [ }, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
translateX,
backdropOpacity,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const animateToClose = useCallback(() => { const animateToClose = useCallback(() => {
"worklet"; "worklet";
@@ -335,21 +330,13 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
(finished) => { (finished) => {
if (!finished) return; if (!finished) return;
settleMobilePanel("agent"); settleMobilePanel("agent");
runOnJS(bumpSettledGeneration)();
}, },
); );
backdropOpacity.value = withTiming(0, { backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION, duration: ANIMATION_DURATION,
easing: ANIMATION_EASING, easing: ANIMATION_EASING,
}); });
}, [ }, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
translateX,
backdropOpacity,
windowWidth,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const value = useMemo<SidebarAnimationContextValue>( const value = useMemo<SidebarAnimationContextValue>(
() => ({ () => ({
@@ -360,6 +347,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToClose, animateToClose,
startMobilePanelTransition, startMobilePanelTransition,
settleMobilePanel, settleMobilePanel,
overlayVisible,
setOverlayPeek,
isGesturing, isGesturing,
mobileVisualPanel, mobileVisualPanel,
mobilePanelState, mobilePanelState,
@@ -375,6 +364,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
animateToClose, animateToClose,
startMobilePanelTransition, startMobilePanelTransition,
settleMobilePanel, settleMobilePanel,
overlayVisible,
isGesturing, isGesturing,
mobileVisualPanel, mobileVisualPanel,
mobilePanelState, mobilePanelState,
@@ -385,11 +375,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
); );
return ( return (
<SidebarAnimationContext.Provider value={value}> <SidebarAnimationContext.Provider value={value}>{children}</SidebarAnimationContext.Provider>
<SidebarSettledGenerationContext.Provider value={settledGeneration}>
{children}
</SidebarSettledGenerationContext.Provider>
</SidebarAnimationContext.Provider>
); );
} }
@@ -400,7 +386,3 @@ export function useSidebarAnimation() {
} }
return context; return context;
} }
export function useSidebarSettledGeneration() {
return useContext(SidebarSettledGenerationContext);
}
@@ -20,6 +20,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
windowWidth, windowWidth,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
setOverlayPeek,
isGesturing, isGesturing,
gestureAnimatingRef, gestureAnimatingRef,
openGestureRef, openGestureRef,
@@ -93,6 +94,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
}) })
.onStart(() => { .onStart(() => {
isGesturing.value = true; isGesturing.value = true;
// The overlay is display:none while closed; reveal it for the drag.
runOnJS(setOverlayPeek)(true);
}) })
.onUpdate((event) => { .onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0. // Right sidebar: start from closed position (+windowWidth) and move towards 0.
@@ -122,6 +125,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
}) })
.onFinalize(() => { .onFinalize(() => {
isGesturing.value = false; isGesturing.value = false;
runOnJS(setOverlayPeek)(false);
}), }),
[ [
enabled, enabled,
@@ -131,6 +135,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
mobilePanelState, mobilePanelState,
animateToOpen, animateToOpen,
animateToClose, animateToClose,
setOverlayPeek,
isGesturing, isGesturing,
openGestureRef, openGestureRef,
leftOpenGestureRef, leftOpenGestureRef,