Fix mobile sidebar swipes under load
Move mobile sidebar gesture gating to shared visual panel state so swipes can open and close sidebars before React state catches up. Keep the sidebar list scroll simultaneous with the close pan so vertical scrolling stays immediate.
This commit is contained in:
@@ -40,6 +40,8 @@ import {
|
|||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { SessionProvider } from "@/contexts/session-context";
|
import { SessionProvider } from "@/contexts/session-context";
|
||||||
import {
|
import {
|
||||||
|
MOBILE_VISUAL_PANEL_AGENT,
|
||||||
|
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||||
SidebarAnimationProvider,
|
SidebarAnimationProvider,
|
||||||
useSidebarAnimation,
|
useSidebarAnimation,
|
||||||
} from "@/contexts/sidebar-animation-context";
|
} from "@/contexts/sidebar-animation-context";
|
||||||
@@ -493,7 +495,6 @@ function MobileGestureWrapper({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
chromeEnabled: boolean;
|
chromeEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
|
||||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const {
|
const {
|
||||||
@@ -503,12 +504,13 @@ function MobileGestureWrapper({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
mobileVisualPanel,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const touchStartY = useSharedValue(0);
|
const touchStartY = useSharedValue(0);
|
||||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
const openGestureEnabled = chromeEnabled;
|
||||||
|
|
||||||
const handleGestureOpen = useCallback(() => {
|
const handleGestureOpen = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
@@ -538,6 +540,11 @@ function MobileGestureWrapper({
|
|||||||
const absDeltaX = Math.abs(deltaX);
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY);
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
@@ -579,9 +586,11 @@ function MobileGestureWrapper({
|
|||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
|
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(handleGestureOpen)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
|
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -593,6 +602,7 @@ function MobileGestureWrapper({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
|
mobileVisualPanel,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleGestureOpen,
|
handleGestureOpen,
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import {
|
|||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
|
import {
|
||||||
|
MOBILE_VISUAL_PANEL_AGENT,
|
||||||
|
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
||||||
|
useSidebarAnimation,
|
||||||
|
} from "@/contexts/sidebar-animation-context";
|
||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { GitDiffPane } from "@/git/diff-pane";
|
import { GitDiffPane } from "@/git/diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
@@ -63,6 +68,8 @@ export function ExplorerSidebar({
|
|||||||
const { width: viewportWidth } = useWindowDimensions();
|
const { width: viewportWidth } = useWindowDimensions();
|
||||||
const closeTouchStartX = useSharedValue(0);
|
const closeTouchStartX = useSharedValue(0);
|
||||||
const closeTouchStartY = useSharedValue(0);
|
const closeTouchStartY = useSharedValue(0);
|
||||||
|
const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||||
|
useSidebarAnimation();
|
||||||
|
|
||||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||||
mode: "padding",
|
mode: "padding",
|
||||||
@@ -114,10 +121,11 @@ export function ExplorerSidebar({
|
|||||||
|
|
||||||
const handleCloseFromGesture = useCallback(() => {
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
|
mobilePanelGestureAnimatingRef.current = true;
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
}, [gestureAnimatingRef, showMobileAgent]);
|
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile;
|
||||||
|
|
||||||
const handleTabPress = useCallback(
|
const handleTabPress = useCallback(
|
||||||
(tab: ExplorerTab) => {
|
(tab: ExplorerTab) => {
|
||||||
@@ -158,6 +166,11 @@ export function ExplorerSidebar({
|
|||||||
const absDeltaX = Math.abs(deltaX);
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY);
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
||||||
if (deltaX <= -10) {
|
if (deltaX <= -10) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
@@ -193,9 +206,11 @@ export function ExplorerSidebar({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
});
|
});
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
|
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleCloseFromGesture)();
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
|
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -207,6 +222,7 @@ export function ExplorerSidebar({
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
|
mobileVisualPanel,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleCloseFromGesture,
|
handleCloseFromGesture,
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ 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 { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import {
|
||||||
|
MOBILE_VISUAL_PANEL_AGENT,
|
||||||
|
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||||
|
useSidebarAnimation,
|
||||||
|
} 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";
|
||||||
@@ -567,6 +571,7 @@ function MobileSidebar({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
mobileVisualPanel,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
@@ -603,7 +608,7 @@ function MobileSidebar({
|
|||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.withRef(closeGestureRef)
|
.withRef(closeGestureRef)
|
||||||
.enabled(isOpen)
|
.enabled(true)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
const touch = event.changedTouches[0];
|
const touch = event.changedTouches[0];
|
||||||
@@ -625,6 +630,11 @@ function MobileSidebar({
|
|||||||
const absDeltaX = Math.abs(deltaX);
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY);
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (deltaX >= 10) {
|
if (deltaX >= 10) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
return;
|
return;
|
||||||
@@ -654,9 +664,11 @@ function MobileSidebar({
|
|||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
|
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleCloseFromGesture)();
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
|
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -664,11 +676,11 @@ function MobileSidebar({
|
|||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isOpen,
|
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
closeTouchStartX,
|
closeTouchStartX,
|
||||||
closeTouchStartY,
|
closeTouchStartY,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
mobileVisualPanel,
|
||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
|
|||||||
@@ -2450,7 +2450,19 @@ function ProjectModeList({
|
|||||||
);
|
);
|
||||||
const selectionEnabled = isWorkspaceRoute;
|
const selectionEnabled = isWorkspaceRoute;
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
|
const nativeScrollGestureProps = useMemo(
|
||||||
|
() =>
|
||||||
|
parentGestureRef
|
||||||
|
? ({
|
||||||
|
// NestableScrollContainer forwards props to RNGH ScrollView. Keep
|
||||||
|
// vertical scroll and sidebar close pan simultaneous: vertical
|
||||||
|
// intent scrolls immediately, clear horizontal intent can still
|
||||||
|
// activate close from inside the list.
|
||||||
|
simultaneousHandlers: parentGestureRef,
|
||||||
|
} as object)
|
||||||
|
: undefined,
|
||||||
|
[parentGestureRef],
|
||||||
|
);
|
||||||
const projectIconRequests = useMemo(() => {
|
const projectIconRequests = useMemo(() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return [];
|
return [];
|
||||||
@@ -2724,6 +2736,7 @@ function ProjectModeList({
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{platformIsNative ? (
|
{platformIsNative ? (
|
||||||
<NestableScrollContainer
|
<NestableScrollContainer
|
||||||
|
{...nativeScrollGestureProps}
|
||||||
style={styles.list}
|
style={styles.list}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import { useWindowDimensions } from "react-native";
|
|||||||
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
|
import { 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 {
|
||||||
|
MOBILE_VISUAL_PANEL_AGENT,
|
||||||
|
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||||
|
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
||||||
|
useSidebarAnimation,
|
||||||
|
} from "@/contexts/sidebar-animation-context";
|
||||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
getRightSidebarAnimationTargets,
|
getRightSidebarAnimationTargets,
|
||||||
@@ -35,9 +41,21 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
||||||
|
if (mobileView === "agent-list") {
|
||||||
|
return MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||||
|
}
|
||||||
|
if (mobileView === "file-explorer") {
|
||||||
|
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||||
|
}
|
||||||
|
return MOBILE_VISUAL_PANEL_AGENT;
|
||||||
|
}
|
||||||
|
|
||||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { mobileVisualPanel } = useSidebarAnimation();
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const isOpen = usePanelStore((state) =>
|
const isOpen = usePanelStore((state) =>
|
||||||
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
|
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
|
||||||
);
|
);
|
||||||
@@ -53,6 +71,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
|
const prevMobileView = useRef(mobileView);
|
||||||
const prevWindowWidth = useRef(windowWidth);
|
const prevWindowWidth = useRef(windowWidth);
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
@@ -63,11 +82,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
previousWindowWidth: prevWindowWidth.current,
|
previousWindowWidth: prevWindowWidth.current,
|
||||||
nextWindowWidth: windowWidth,
|
nextWindowWidth: windowWidth,
|
||||||
});
|
});
|
||||||
|
const didMobileViewChange = prevMobileView.current !== mobileView;
|
||||||
const previousIsOpen = prevIsOpen.current;
|
const previousIsOpen = prevIsOpen.current;
|
||||||
prevIsOpen.current = isOpen;
|
prevIsOpen.current = isOpen;
|
||||||
|
prevMobileView.current = mobileView;
|
||||||
prevWindowWidth.current = windowWidth;
|
prevWindowWidth.current = windowWidth;
|
||||||
|
|
||||||
if (!didStateChange) {
|
if (!didStateChange && !didMobileViewChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +102,10 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCompactLayout) {
|
||||||
|
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
|
||||||
|
}
|
||||||
|
|
||||||
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
|
|
||||||
if (previousIsOpen !== isOpen) {
|
if (previousIsOpen !== isOpen) {
|
||||||
@@ -97,7 +122,16 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
|
|
||||||
translateX.value = targets.translateX;
|
translateX.value = targets.translateX;
|
||||||
backdropOpacity.value = targets.backdropOpacity;
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing]);
|
}, [
|
||||||
|
isOpen,
|
||||||
|
mobileView,
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
isGesturing,
|
||||||
|
isCompactLayout,
|
||||||
|
mobileVisualPanel,
|
||||||
|
]);
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
const animateToOpen = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
|
|||||||
@@ -20,6 +20,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);
|
||||||
|
export const MOBILE_VISUAL_PANEL_AGENT = 0;
|
||||||
|
export const MOBILE_VISUAL_PANEL_AGENT_LIST = 1;
|
||||||
|
export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2;
|
||||||
|
|
||||||
interface SidebarAnimationContextValue {
|
interface SidebarAnimationContextValue {
|
||||||
translateX: SharedValue<number>;
|
translateX: SharedValue<number>;
|
||||||
@@ -28,6 +31,7 @@ interface SidebarAnimationContextValue {
|
|||||||
animateToOpen: () => void;
|
animateToOpen: () => void;
|
||||||
animateToClose: () => void;
|
animateToClose: () => void;
|
||||||
isGesturing: SharedValue<boolean>;
|
isGesturing: SharedValue<boolean>;
|
||||||
|
mobileVisualPanel: SharedValue<number>;
|
||||||
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||||
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
@@ -35,9 +39,20 @@ interface SidebarAnimationContextValue {
|
|||||||
|
|
||||||
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
|
||||||
|
|
||||||
|
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
|
||||||
|
if (mobileView === "agent-list") {
|
||||||
|
return MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||||
|
}
|
||||||
|
if (mobileView === "file-explorer") {
|
||||||
|
return MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||||
|
}
|
||||||
|
return MOBILE_VISUAL_PANEL_AGENT;
|
||||||
|
}
|
||||||
|
|
||||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const isOpen = usePanelStore((state) =>
|
const isOpen = usePanelStore((state) =>
|
||||||
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
);
|
);
|
||||||
@@ -47,12 +62,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
const translateX = useSharedValue(initialTargets.translateX);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
|
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
|
||||||
const gestureAnimatingRef = useRef(false);
|
const gestureAnimatingRef = useRef(false);
|
||||||
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
const prevIsOpen = useRef(isOpen);
|
const prevIsOpen = useRef(isOpen);
|
||||||
|
const prevMobileView = useRef(mobileView);
|
||||||
const prevWindowWidth = useRef(windowWidth);
|
const prevWindowWidth = useRef(windowWidth);
|
||||||
|
|
||||||
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
|
||||||
@@ -63,12 +80,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
previousWindowWidth: prevWindowWidth.current,
|
previousWindowWidth: prevWindowWidth.current,
|
||||||
nextWindowWidth: windowWidth,
|
nextWindowWidth: windowWidth,
|
||||||
});
|
});
|
||||||
|
const didMobileViewChange = prevMobileView.current !== mobileView;
|
||||||
const previousIsOpen = prevIsOpen.current;
|
const previousIsOpen = prevIsOpen.current;
|
||||||
prevIsOpen.current = isOpen;
|
prevIsOpen.current = isOpen;
|
||||||
|
prevMobileView.current = mobileView;
|
||||||
prevWindowWidth.current = windowWidth;
|
prevWindowWidth.current = windowWidth;
|
||||||
const didOpen = !previousIsOpen && isOpen;
|
const didOpen = !previousIsOpen && isOpen;
|
||||||
|
|
||||||
if (!didStateChange) {
|
if (!didStateChange && !didMobileViewChange) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +108,10 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCompactLayout) {
|
||||||
|
mobileVisualPanel.value = getMobileVisualPanel(mobileView);
|
||||||
|
}
|
||||||
|
|
||||||
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
|
||||||
|
|
||||||
if (previousIsOpen !== isOpen) {
|
if (previousIsOpen !== isOpen) {
|
||||||
@@ -105,7 +128,16 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
|
|
||||||
translateX.value = targets.translateX;
|
translateX.value = targets.translateX;
|
||||||
backdropOpacity.value = targets.backdropOpacity;
|
backdropOpacity.value = targets.backdropOpacity;
|
||||||
}, [isOpen, translateX, backdropOpacity, windowWidth, isGesturing, isCompactLayout]);
|
}, [
|
||||||
|
isOpen,
|
||||||
|
mobileView,
|
||||||
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
isGesturing,
|
||||||
|
isCompactLayout,
|
||||||
|
mobileVisualPanel,
|
||||||
|
]);
|
||||||
|
|
||||||
const animateToOpen = useCallback(() => {
|
const animateToOpen = useCallback(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
@@ -139,6 +171,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
mobileVisualPanel,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
@@ -150,6 +183,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
mobileVisualPanel,
|
||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { useCallback, useMemo } from "react";
|
|||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
import {
|
||||||
|
MOBILE_VISUAL_PANEL_AGENT,
|
||||||
|
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
||||||
|
useSidebarAnimation,
|
||||||
|
} from "@/contexts/sidebar-animation-context";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
interface UseExplorerOpenGestureParams {
|
interface UseExplorerOpenGestureParams {
|
||||||
@@ -23,14 +27,19 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
gestureAnimatingRef,
|
gestureAnimatingRef,
|
||||||
openGestureRef,
|
openGestureRef,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
const { openGestureRef: leftOpenGestureRef } = useSidebarAnimation();
|
const {
|
||||||
|
mobileVisualPanel,
|
||||||
|
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
|
||||||
|
openGestureRef: leftOpenGestureRef,
|
||||||
|
} = useSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const touchStartY = useSharedValue(0);
|
const touchStartY = useSharedValue(0);
|
||||||
|
|
||||||
const handleGestureOpen = useCallback(() => {
|
const handleGestureOpen = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
|
mobilePanelGestureAnimatingRef.current = true;
|
||||||
onOpen();
|
onOpen();
|
||||||
}, [onOpen, gestureAnimatingRef]);
|
}, [onOpen, gestureAnimatingRef, mobilePanelGestureAnimatingRef]);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -59,6 +68,11 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
const absDeltaX = Math.abs(deltaX);
|
const absDeltaX = Math.abs(deltaX);
|
||||||
const absDeltaY = Math.abs(deltaY);
|
const absDeltaY = Math.abs(deltaY);
|
||||||
|
|
||||||
|
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
|
||||||
|
stateManager.fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Browser back-swipe owns most of the viewport; keep this gesture on the right edge.
|
// Browser back-swipe owns most of the viewport; keep this gesture on the right edge.
|
||||||
if (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
if (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) {
|
||||||
stateManager.fail();
|
stateManager.fail();
|
||||||
@@ -103,9 +117,11 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
const shouldOpenByVelocity = event.velocityX < -500;
|
const shouldOpenByVelocity = event.velocityX < -500;
|
||||||
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
|
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(handleGestureOpen)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
|
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -117,6 +133,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
|
mobileVisualPanel,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
|||||||
@@ -774,19 +774,17 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
|||||||
|
|
||||||
interface MobileExplorerOpenGestureSurfaceProps {
|
interface MobileExplorerOpenGestureSurfaceProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
enabled: boolean;
|
||||||
onOpenExplorer: () => void;
|
onOpenExplorer: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileExplorerOpenGestureSurface({
|
function MobileExplorerOpenGestureSurface({
|
||||||
children,
|
children,
|
||||||
|
enabled,
|
||||||
onOpenExplorer,
|
onOpenExplorer,
|
||||||
}: MobileExplorerOpenGestureSurfaceProps) {
|
}: MobileExplorerOpenGestureSurfaceProps) {
|
||||||
const canOpenExplorerFromAgentView = usePanelStore(
|
|
||||||
(state) =>
|
|
||||||
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
|
|
||||||
);
|
|
||||||
const explorerOpenGesture = useExplorerOpenGesture({
|
const explorerOpenGesture = useExplorerOpenGesture({
|
||||||
enabled: canOpenExplorerFromAgentView,
|
enabled,
|
||||||
onOpen: onOpenExplorer,
|
onOpen: onOpenExplorer,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3444,7 +3442,10 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
<View style={styles.centerContent}>
|
<View style={styles.centerContent}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<MobileExplorerOpenGestureSurface onOpenExplorer={openExplorerForWorkspace}>
|
<MobileExplorerOpenGestureSurface
|
||||||
|
enabled={Boolean(activeExplorerCheckout)}
|
||||||
|
onOpenExplorer={openExplorerForWorkspace}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</MobileExplorerOpenGestureSurface>
|
</MobileExplorerOpenGestureSurface>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user