From 9ad3e7661ba7103265e3272f5b22a26d38ae04f6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 28 Mar 2026 10:50:24 +0700 Subject: [PATCH] Fix mobile sidebar reset after theme switch (#152) --- .../explorer-sidebar-animation-context.tsx | 48 +++++++++++-------- .../contexts/sidebar-animation-context.tsx | 45 ++++++++++------- packages/app/src/screens/settings-screen.tsx | 6 --- .../src/utils/sidebar-animation-state.test.ts | 44 +++++++++++++++++ .../app/src/utils/sidebar-animation-state.ts | 41 ++++++++++++++++ 5 files changed, 141 insertions(+), 43 deletions(-) create mode 100644 packages/app/src/utils/sidebar-animation-state.test.ts create mode 100644 packages/app/src/utils/sidebar-animation-state.ts diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index c9b724375..156776a2f 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -4,6 +4,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati import { type GestureType } from "react-native-gesture-handler"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; +import { + getRightSidebarAnimationTargets, + shouldSyncSidebarAnimation, +} from "@/utils/sidebar-animation-state"; const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); @@ -31,55 +35,59 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; // Right sidebar: closed = +windowWidth (off-screen right), open = 0 - const translateX = useSharedValue(isOpen ? 0 : windowWidth); - const backdropOpacity = useSharedValue(isOpen ? 1 : 0); + const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth }); + const translateX = useSharedValue(initialTargets.translateX); + const backdropOpacity = useSharedValue(initialTargets.backdropOpacity); const isGesturing = useSharedValue(false); const closeGestureRef = useRef(undefined); // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); + const prevWindowWidth = useRef(windowWidth); // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) useEffect(() => { - // Skip if this is initial render or if we're mid-gesture - if (prevIsOpen.current === isOpen) { - return; - } + const didStateChange = shouldSyncSidebarAnimation({ + previousIsOpen: prevIsOpen.current, + nextIsOpen: isOpen, + previousWindowWidth: prevWindowWidth.current, + nextWindowWidth: windowWidth, + }); const previousIsOpen = prevIsOpen.current; prevIsOpen.current = isOpen; + prevWindowWidth.current = windowWidth; + + if (!didStateChange) { + return; + } // Don't animate if we're in the middle of a gesture - the gesture handler will handle it if (isGesturing.value) { return; } - if (isOpen) { - translateX.value = withTiming(0, { + const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth }); + + if (previousIsOpen !== isOpen) { + translateX.value = withTiming(targets.translateX, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - backdropOpacity.value = withTiming(1, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - } else { - translateX.value = withTiming(windowWidth, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - backdropOpacity.value = withTiming(0, { + backdropOpacity.value = withTiming(targets.backdropOpacity, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); + return; } + + translateX.value = targets.translateX; + backdropOpacity.value = targets.backdropOpacity; }, [ isOpen, translateX, backdropOpacity, windowWidth, isGesturing, - mobileView, - desktopFileExplorerOpen, ]); const animateToOpen = () => { diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index c2a399937..473359b27 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -12,6 +12,10 @@ import { useSharedValue, withTiming, Easing, type SharedValue } from "react-nati import { type GestureType } from "react-native-gesture-handler"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; +import { + getLeftSidebarAnimationTargets, + shouldSyncSidebarAnimation, +} from "@/utils/sidebar-animation-state"; const ANIMATION_DURATION = 220; const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1); @@ -38,46 +42,53 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; // Initialize based on current state - const translateX = useSharedValue(isOpen ? 0 : -windowWidth); - const backdropOpacity = useSharedValue(isOpen ? 1 : 0); + const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth }); + const translateX = useSharedValue(initialTargets.translateX); + const backdropOpacity = useSharedValue(initialTargets.backdropOpacity); const isGesturing = useSharedValue(false); const closeGestureRef = useRef(undefined); // Track previous isOpen to detect changes const prevIsOpen = useRef(isOpen); + const prevWindowWidth = useRef(windowWidth); // Sync animation with store state changes (e.g., backdrop tap, programmatic open/close) useEffect(() => { - // Skip if this is initial render or if we're mid-gesture - if (prevIsOpen.current === isOpen) { + const didStateChange = shouldSyncSidebarAnimation({ + previousIsOpen: prevIsOpen.current, + nextIsOpen: isOpen, + previousWindowWidth: prevWindowWidth.current, + nextWindowWidth: windowWidth, + }); + const previousIsOpen = prevIsOpen.current; + prevIsOpen.current = isOpen; + prevWindowWidth.current = windowWidth; + + if (!didStateChange) { return; } - prevIsOpen.current = isOpen; // Don't animate if we're in the middle of a gesture - the gesture handler will handle it if (isGesturing.value) { return; } - if (isOpen) { - translateX.value = withTiming(0, { + const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth }); + + if (previousIsOpen !== isOpen) { + translateX.value = withTiming(targets.translateX, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); - backdropOpacity.value = withTiming(1, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - } else { - translateX.value = withTiming(-windowWidth, { - duration: ANIMATION_DURATION, - easing: ANIMATION_EASING, - }); - backdropOpacity.value = withTiming(0, { + backdropOpacity.value = withTiming(targets.backdropOpacity, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING, }); + return; } + + translateX.value = targets.translateX; + backdropOpacity.value = targets.backdropOpacity; }, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]); const animateToOpen = useCallback(() => { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 19200ee63..25f7cb71d 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -896,12 +896,6 @@ export default function SettingsScreen() { const handleThemeChange = useCallback( (newTheme: AppSettings["theme"]) => { void updateSettings({ theme: newTheme }); - if (newTheme === "auto") { - UnistylesRuntime.setAdaptiveThemes(true); - } else { - UnistylesRuntime.setAdaptiveThemes(false); - UnistylesRuntime.setTheme(newTheme); - } }, [updateSettings], ); diff --git a/packages/app/src/utils/sidebar-animation-state.test.ts b/packages/app/src/utils/sidebar-animation-state.test.ts new file mode 100644 index 000000000..5d587902b --- /dev/null +++ b/packages/app/src/utils/sidebar-animation-state.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + getLeftSidebarAnimationTargets, + getRightSidebarAnimationTargets, + shouldSyncSidebarAnimation, +} from "./sidebar-animation-state"; + +describe("sidebar-animation-state", () => { + it("requests a sync when the open state changes", () => { + expect( + shouldSyncSidebarAnimation({ + previousIsOpen: false, + nextIsOpen: true, + previousWindowWidth: 390, + nextWindowWidth: 390, + }), + ).toBe(true); + }); + + it("requests a sync when the viewport width changes", () => { + expect( + shouldSyncSidebarAnimation({ + previousIsOpen: false, + nextIsOpen: false, + previousWindowWidth: 390, + nextWindowWidth: 430, + }), + ).toBe(true); + }); + + it("keeps the left sidebar fully off-screen when closed", () => { + expect(getLeftSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({ + translateX: -430, + backdropOpacity: 0, + }); + }); + + it("keeps the right sidebar fully off-screen when closed", () => { + expect(getRightSidebarAnimationTargets({ isOpen: false, windowWidth: 430 })).toEqual({ + translateX: 430, + backdropOpacity: 0, + }); + }); +}); diff --git a/packages/app/src/utils/sidebar-animation-state.ts b/packages/app/src/utils/sidebar-animation-state.ts new file mode 100644 index 000000000..9572b8975 --- /dev/null +++ b/packages/app/src/utils/sidebar-animation-state.ts @@ -0,0 +1,41 @@ +interface SidebarAnimationSyncInput { + previousIsOpen: boolean; + nextIsOpen: boolean; + previousWindowWidth: number; + nextWindowWidth: number; +} + +interface SidebarAnimationTargetInput { + isOpen: boolean; + windowWidth: number; +} + +interface SidebarAnimationTargets { + translateX: number; + backdropOpacity: number; +} + +export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean { + return ( + input.previousIsOpen !== input.nextIsOpen || + input.previousWindowWidth !== input.nextWindowWidth + ); +} + +export function getLeftSidebarAnimationTargets( + input: SidebarAnimationTargetInput, +): SidebarAnimationTargets { + return { + translateX: input.isOpen ? 0 : -input.windowWidth, + backdropOpacity: input.isOpen ? 1 : 0, + }; +} + +export function getRightSidebarAnimationTargets( + input: SidebarAnimationTargetInput, +): SidebarAnimationTargets { + return { + translateX: input.isOpen ? 0 : input.windowWidth, + backdropOpacity: input.isOpen ? 1 : 0, + }; +}