Stop mobile sidebars getting stuck out of sync (#1953)

* fix(app): keep mobile sidebars synced with store

Gesture-owned skip flags could swallow the next store sync after a no-op or overlapping gesture, leaving React state and Reanimated shared values split. Always reconcile both mobile sidebars from the panel store after gesture animations.

* test(app): keep sidebar sync coverage pure

* fix(app): keep sidebar close transitions idempotent

* fix(app): keep mobile sidebars stable through interruptions

Independent React and worklet state let stale gesture or animation callbacks
overwrite newer panel commands. Use one revisioned target and one transient
motion runtime so outdated callbacks cannot restore an earlier panel.
This commit is contained in:
Mohamed Boudra
2026-07-09 23:08:56 +02:00
committed by GitHub
parent 15d3091104
commit 6af1379357
21 changed files with 1434 additions and 1651 deletions

View File

@@ -45,6 +45,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | | [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | | [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | | [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha | | [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows | | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |

85
docs/mobile-panels.md Normal file
View File

@@ -0,0 +1,85 @@
# Mobile panels
Compact layouts have three mutually exclusive destinations:
- `agent-list` on the left
- `agent` in the center
- `file-explorer` on the right
They are one interaction, not two independent drawers. The implementation lives in
`packages/app/src/mobile-panels/`.
## Ownership
React/Zustand owns the durable intent:
```ts
interface MobilePanelSelection {
target: "agent-list" | "agent" | "file-explorer";
revision: number;
}
```
Every semantic target change increments `revision`. Repeating the current target is idempotent.
Compact panel selection is not persisted; a cold start begins at `agent`.
The UI worklet owns transient motion:
- one normalized position (`-1` left, `0` center, `1` right)
- the current motion target
- the active gesture's starting revision
- the last settled target
React also owns presentation lifecycle: whether an overlay is mounted/displayed and whether it may
receive pointer events. Worklets never own `display` or `pointerEvents`.
## Why one position
Both transforms and both backdrop opacities are derived from the same normalized position. Window
width is only a projection input. Rotation changes the projection, not the panel state.
This makes these invalid states unrepresentable:
- a panel and its backdrop disagreeing
- left and right drawers both claiming to be open
- a width-sync effect resetting an active drag
- one animation context settling a transition owned by the other
Do not add another panel translate shared value, backdrop shared value, or width synchronization
effect.
## Ordering and interruption
A gesture captures the current revision when it becomes active. Per-frame updates are accepted only
while that revision still owns the gesture.
When a React command arrives during a drag, its newer revision clears gesture ownership and starts
motion toward the new target. The older gesture's remaining updates and finish callback are ignored.
Canceled gestures return to the latest canonical target. Animation completion is accepted only when
its target and revision still match the canonical command.
Manual gesture arbitration has two phases:
1. Before activation, determine whether horizontal intent may begin.
2. After activation, stop running begin checks and let the active revision own updates.
Re-running the begin gate after activation self-cancels the gesture because an active gesture is, by
definition, no longer eligible to begin.
## Integration rules
- Callers request semantic targets through `panel-store`; they never write shared values.
- Gesture behavior comes from the four explicit hooks in `mobile-panels/gestures.ts`.
- Mobile sidebars render through `MobilePanelOverlay`; do not duplicate overlay lifecycle or motion
styles in sidebar components.
- Animated panel nodes use React Native static styles plus inline theme values. Do not attach
Unistyles-generated styles to those nodes; Unistyles and Reanimated patching the same Fabric node
has caused native crashes.
- The plain React wrapper owns `display: none` after settlement. This prevents a stale Fabric animated
prop commit from resurrecting a closed overlay.
## Tests
`packages/app/src/mobile-panels/model.test.ts` exercises command, drag, cancellation, interruption,
rapid-command, stale-completion, and width-projection sequences through the transition model. Add a
sequence there whenever ownership or ordering changes.

View File

@@ -23,9 +23,8 @@ import {
useSyncExternalStore, useSyncExternalStore,
} from "react"; } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller"; import { KeyboardProvider } from "react-native-keyboard-controller";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center"; import { CommandCenter } from "@/components/command-center";
@@ -44,16 +43,8 @@ import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser"; import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform"; import { isNative, isWeb } from "@/constants/platform";
import { import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
HorizontalScrollProvider,
useHorizontalScrollOptional,
} from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context"; import { SessionProvider } from "@/contexts/session-context";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import {
SidebarAnimationProvider,
useSidebarAnimation,
} from "@/contexts/sidebar-animation-context";
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context"; import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
import { ToastProvider } from "@/contexts/toast-context"; import { ToastProvider } from "@/contexts/toast-context";
import { VoiceProvider } from "@/contexts/voice-context"; import { VoiceProvider } from "@/contexts/voice-context";
@@ -82,6 +73,8 @@ import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-
import { useOpenProject } from "@/hooks/use-open-project"; import { useOpenProject } from "@/hooks/use-open-project";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event"; import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider } from "@/mobile-panels/provider";
import { I18nProvider } from "@/i18n/provider"; import { I18nProvider } from "@/i18n/provider";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { polyfillCrypto } from "@/polyfills/crypto"; import { polyfillCrypto } from "@/polyfills/crypto";
@@ -100,7 +93,6 @@ import { usePanelStore } from "@/stores/panel-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme"; import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle"; import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import { import {
buildOpenProjectRoute, buildOpenProjectRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
@@ -405,7 +397,6 @@ function QueryProvider({ children }: { children: ReactNode }) {
const rowStyle = { flex: 1, flexDirection: "row" } as const; const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const; const flexStyle = { flex: 1 } as const;
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y"; const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
interface AppContainerProps { interface AppContainerProps {
@@ -481,11 +472,9 @@ function AppContainer({
<LeftSidebar selectedAgentId={selectedAgentId} /> <LeftSidebar selectedAgentId={selectedAgentId} />
)} )}
{isCompactLayout && chromeEnabled ? ( {isCompactLayout && chromeEnabled ? (
<ExplorerSidebarAnimationProvider> <CompactExplorerSidebarHost enabled={chromeEnabled}>
<CompactExplorerSidebarHost enabled={chromeEnabled}> <View style={flexStyle}>{children}</View>
<View style={flexStyle}>{children}</View> </CompactExplorerSidebarHost>
</CompactExplorerSidebarHost>
</ExplorerSidebarAnimationProvider>
) : ( ) : (
<View style={flexStyle}>{children}</View> <View style={flexStyle}>{children}</View>
)} )}
@@ -526,127 +515,7 @@ function MobileGestureWrapper({
children: ReactNode; children: ReactNode;
chromeEnabled: boolean; chromeEnabled: boolean;
}) { }) {
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); const openGesture = useOpenAgentListGesture(chromeEnabled);
const horizontalScroll = useHorizontalScrollOptional();
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
setOverlayPeek,
isGesturing,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const openGestureEnabled = chromeEnabled;
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
showMobileAgentList();
}, [showMobileAgentList, gestureAnimatingRef]);
const openGesture = useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.enabled(openGestureEnabled)
.manualActivation(true)
.failOffsetY([-10, 10])
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) return;
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canOpenLeftSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
if (horizontalScroll?.isAnyScrolledRight.value) {
stateManager.fail();
return;
}
if (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) {
stateManager.fail();
return;
}
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
if (deltaX > 15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
// The overlay is display:none while closed; reveal it for the drag.
runOnJS(setOverlayPeek)(true);
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, -windowWidth + event.translationX);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
if (shouldOpen) {
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
})
.onFinalize(() => {
isGesturing.value = false;
runOnJS(setOverlayPeek)(false);
}),
[
openGestureEnabled,
windowWidth,
translateX,
backdropOpacity,
mobilePanelState,
animateToOpen,
animateToClose,
setOverlayPeek,
handleGestureOpen,
isGesturing,
openGestureRef,
horizontalScroll?.isAnyScrolledRight,
touchStartX,
touchStartY,
],
);
return ( return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}> <GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
@@ -974,7 +843,7 @@ function WorkspaceRouteNavigationBridge() {
function AppShell() { function AppShell() {
return ( return (
<SidebarAnimationProvider> <MobilePanelsProvider>
<HorizontalScrollProvider> <HorizontalScrollProvider>
<OpenProjectListener /> <OpenProjectListener />
<AppWithSidebar> <AppWithSidebar>
@@ -982,7 +851,7 @@ function AppShell() {
<RootStack /> <RootStack />
</AppWithSidebar> </AppWithSidebar>
</HorizontalScrollProvider> </HorizontalScrollProvider>
</SidebarAnimationProvider> </MobilePanelsProvider>
); );
} }

View File

@@ -4,7 +4,7 @@ import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks"; import { useWorkspace } from "@/stores/session-store-hooks";
import { CompactExplorerSidebar } from "@/components/explorer-sidebar"; import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -29,7 +29,7 @@ function CompactExplorerOpenGestureSurface({
enabled, enabled,
onOpenExplorer, onOpenExplorer,
}: CompactExplorerOpenGestureSurfaceProps) { }: CompactExplorerOpenGestureSurfaceProps) {
const explorerOpenGesture = useExplorerOpenGesture({ const explorerOpenGesture = useOpenFileExplorerGesture({
enabled, enabled,
onOpen: onOpenExplorer, onOpen: onOpenExplorer,
}); });

View File

@@ -8,7 +8,7 @@ import {
} from "react-native"; } from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context"; import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
interface DiffScrollProps { interface DiffScrollProps {
children: React.ReactNode; children: React.ReactNode;
@@ -30,9 +30,7 @@ export function DiffScroll({
const scrollId = useId(); const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null); const scrollViewRef = useRef<ScrollViewType>(null);
// Get the close gesture ref from animation context (may not be available outside sidebar) const closeGestureRef = useFileExplorerCloseGestureRef();
const animation = useExplorerSidebarAnimationOptional();
const closeGestureRef = animation?.closeGestureRef;
// Register/unregister scroll offset tracking // Register/unregister scroll offset tracking
useEffect(() => { useEffect(() => {

View File

@@ -29,10 +29,9 @@ import {
MAX_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab, type ExplorerTab,
} from "@/stores/panel-store"; } from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state"; import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } 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";
@@ -91,25 +90,11 @@ export function CompactExplorerSidebar({
workspaceRoot, workspaceRoot,
isGit, isGit,
}); });
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({ const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding", mode: "padding",
enabled: true, enabled: true,
}); });
const { const { gesture: closeGesture } = useCloseFileExplorerGesture();
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
} = useExplorerSidebarAnimation();
const handleClose = useCallback( const handleClose = useCallback(
(reason: string) => { (reason: string) => {
@@ -122,184 +107,38 @@ export function CompactExplorerSidebar({
[isOpen, showMobileAgent], [isOpen, showMobileAgent],
); );
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
mobilePanelGestureAnimatingRef.current = true;
showMobileAgent();
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]); const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(true)
// Use manual activation so child views keep touch streams
// unless we detect an intentional right-swipe close.
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canCloseRightSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
// Fail quickly on clear leftward or vertical intent so child views keep control.
if (deltaX <= -10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional rightward movement.
if (deltaX >= 15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
})
.onUpdate((event) => {
// Right sidebar: swipe right to close (positive translationX)
const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
translateX.value = newTranslateX;
const progress = 1 - newTranslateX / windowWidth;
backdropOpacity.value = Math.max(0, Math.min(1, progress));
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
runOnJS(logExplorerSidebar)("closeGestureEnd", {
translationX: event.translationX,
velocityX: event.velocityX,
shouldClose,
windowWidth,
});
if (shouldClose) {
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false;
}),
[
windowWidth,
translateX,
backdropOpacity,
mobilePanelState,
animateToOpen,
animateToClose,
handleCloseFromGesture,
isGesturing,
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
}));
const backdropCombinedStyle = useMemo(
() => [
explorerStaticStyles.backdrop,
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) },
],
[backdropAnimatedStyle, isOpen],
);
const mobileSidebarStyle = useMemo( const mobileSidebarStyle = useMemo(
() => [ () => [
explorerStaticStyles.mobileSidebar,
{ {
width: windowWidth,
paddingTop: insets.top, paddingTop: insets.top,
backgroundColor: theme.colors.surfaceSidebar, backgroundColor: theme.colors.surfaceSidebar,
}, },
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
],
[
windowWidth,
insets.top,
theme.colors.surfaceSidebar,
sidebarAnimatedStyle,
mobileKeyboardInsetStyle, mobileKeyboardInsetStyle,
], ],
[insets.top, theme.colors.surfaceSidebar, 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],
);
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none";
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
return ( return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}> <MobilePanelOverlay
<Animated.View style={backdropCombinedStyle} /> panel="file-explorer"
closeGesture={closeGesture}
<GestureDetector gesture={closeGesture} touchAction="pan-y"> panelStyle={mobileSidebarStyle}
<Animated.View style={mobileSidebarStyle} pointerEvents="auto"> >
<ExplorerSidebarContent <ExplorerSidebarContent
activeTab={explorerTab} activeTab={explorerTab}
onTabPress={handleTabPress} onTabPress={handleTabPress}
onClose={handleHeaderClose} onClose={handleHeaderClose}
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceId}
workspaceRoot={workspaceRoot} workspaceRoot={workspaceRoot}
isGit={isGit} isGit={isGit}
isMobile isMobile
isOpen={isOpen} isOpen={isOpen}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
/> />
</Animated.View> </MobilePanelOverlay>
</GestureDetector>
</View>
); );
} }
@@ -604,17 +443,6 @@ function PrTabContent({
// avoid the "Unable to find node on an unmounted component" crash when Unistyles // avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages. // tries to patch the native node that Reanimated also manages.
const explorerStaticStyles = RNStyleSheet.create({ const explorerStaticStyles = RNStyleSheet.create({
backdrop: {
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
mobileSidebar: {
position: "absolute" as const,
top: 0,
right: 0,
bottom: 0,
overflow: "hidden" as const,
},
desktopSidebar: { desktopSidebar: {
position: "relative" as const, position: "relative" as const,
}, },

View File

@@ -21,13 +21,7 @@ import {
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, { import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
@@ -38,7 +32,6 @@ 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 { 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";
@@ -62,7 +55,8 @@ import {
usePanelStore, usePanelStore,
} from "@/stores/panel-store"; } from "@/stores/panel-store";
import { useWindowControlsPadding } from "@/utils/desktop-window"; import { useWindowControlsPadding } from "@/utils/desktop-window";
import { canCloseLeftSidebarGesture } from "@/utils/sidebar-animation-state"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { import {
buildOpenProjectRoute, buildOpenProjectRoute,
buildNewWorkspaceRoute, buildNewWorkspaceRoute,
@@ -123,7 +117,6 @@ interface SidebarLabels {
interface MobileSidebarProps extends SidebarSharedProps { interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number; insetsTop: number;
insetsBottom: number; insetsBottom: number;
isOpen: boolean;
closeSidebar: () => void; closeSidebar: () => void;
handleViewMoreNavigate: () => void; handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void; handleViewSchedulesNavigate: () => void;
@@ -278,7 +271,6 @@ export const LeftSidebar = memo(function LeftSidebar({
{...sharedProps} {...sharedProps}
insetsTop={insets.top} insetsTop={insets.top}
insetsBottom={insets.bottom} insetsBottom={insets.bottom}
isOpen={isOpen}
closeSidebar={showMobileAgent} closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile} handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile} handleHome={handleHomeMobile}
@@ -576,7 +568,6 @@ function MobileSidebar({
handleOpenHostSettings, handleOpenHostSettings,
insetsTop, insetsTop,
insetsBottom, insetsBottom,
isOpen,
closeSidebar, closeSidebar,
handleViewMoreNavigate, handleViewMoreNavigate,
handleViewSchedulesNavigate, handleViewSchedulesNavigate,
@@ -584,262 +575,112 @@ function MobileSidebar({
const pathname = usePathname(); const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions"); const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules"); const isSchedulesActive = pathname.includes("/schedules");
const { const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
isGesturing,
mobilePanelState,
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
const handleCloseFromGesture = useCallback(() => {
gestureAnimatingRef.current = true;
closeSidebar();
}, [closeSidebar, gestureAnimatingRef]);
const handleViewMore = useCallback(() => { const handleViewMore = useCallback(() => {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar(); closeSidebar();
handleViewMoreNavigate(); handleViewMoreNavigate();
}, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]); }, [closeSidebar, handleViewMoreNavigate]);
const handleViewSchedules = useCallback(() => { const handleViewSchedules = useCallback(() => {
translateX.value = -windowWidth;
backdropOpacity.value = 0;
closeSidebar(); closeSidebar();
handleViewSchedulesNavigate(); handleViewSchedulesNavigate();
}, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]); }, [closeSidebar, handleViewSchedulesNavigate]);
const handleWorkspacePress = useCallback(() => { const handleWorkspacePress = useCallback(() => {
closeSidebar(); closeSidebar();
}, [closeSidebar]); }, [closeSidebar]);
const closeGesture = useMemo(
() =>
Gesture.Pan()
.withRef(closeGestureRef)
.enabled(true)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
closeTouchStartX.value = touch.absoluteX;
closeTouchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - closeTouchStartX.value;
const deltaY = touch.absoluteY - closeTouchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canCloseLeftSidebarGesture(mobilePanelState.value)) {
stateManager.fail();
return;
}
if (deltaX >= 10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
})
.onUpdate((event) => {
const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX));
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[-windowWidth, 0],
[0, 1],
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
if (shouldClose) {
animateToClose();
runOnJS(handleCloseFromGesture)();
} else {
animateToOpen();
}
})
.onFinalize(() => {
isGesturing.value = false;
}),
[
closeGestureRef,
closeTouchStartX,
closeTouchStartY,
isGesturing,
mobilePanelState,
windowWidth,
translateX,
backdropOpacity,
animateToClose,
animateToOpen,
handleCloseFromGesture,
],
);
const mobileSidebarInsetStyle = useMemo( const mobileSidebarInsetStyle = useMemo(
() => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }), () => ({
[windowWidth, insetsTop, insetsBottom], paddingTop: insetsTop,
); paddingBottom: insetsBottom,
backgroundColor: theme.colors.surfaceSidebar,
const sidebarAnimatedStyle = useAnimatedStyle(() => ({ }),
transform: [{ translateX: translateX.value }], [insetsTop, insetsBottom, theme.colors.surfaceSidebar],
}));
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
}));
let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none";
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
const backdropStyle = useMemo(
() => [
staticStyles.backdrop,
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) },
],
[backdropAnimatedStyle, isOpen],
);
const mobileSidebarStyle = useMemo(
() => [
staticStyles.mobileSidebar,
mobileSidebarInsetStyle,
sidebarAnimatedStyle,
{ backgroundColor: 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={overlayStyle} pointerEvents={overlayPointerEvents}> <MobilePanelOverlay
<Animated.View style={backdropStyle} /> panel="agent-list"
closeGesture={closeGesture}
<GestureDetector gesture={closeGesture} touchAction="pan-y"> panelStyle={mobileSidebarInsetStyle}
<Animated.View style={mobileSidebarStyle} pointerEvents="auto"> >
<View style={styles.sidebarContent} pointerEvents="auto"> <View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeaderGroup}> <View style={styles.sidebarHeaderGroup}>
<SidebarNewWorkspaceHeaderRow <SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace} label={labels.newWorkspace}
testID="sidebar-global-new-workspace" testID="sidebar-global-new-workspace"
variant="compact" variant="compact"
shortcutKeys={newWorkspaceKeys} shortcutKeys={newWorkspaceKeys}
onBeforeNavigate={closeSidebar} onBeforeNavigate={closeSidebar}
/> />
<SidebarHeaderRow <SidebarHeaderRow
icon={History} icon={History}
label={labels.sessions} label={labels.sessions}
onPress={handleViewMore} onPress={handleViewMore}
isActive={isSessionsActive} isActive={isSessionsActive}
testID="sidebar-sessions" testID="sidebar-sessions"
variant="compact" variant="compact"
/> />
<SidebarHeaderRow <SidebarHeaderRow
icon={CalendarClock} icon={CalendarClock}
label={labels.schedules} label={labels.schedules}
onPress={handleViewSchedules} onPress={handleViewSchedules}
isActive={isSchedulesActive} isActive={isSchedulesActive}
testID="sidebar-schedules" testID="sidebar-schedules"
variant="compact" variant="compact"
/> />
</View> </View>
<WorkspacesSectionHeader /> <WorkspacesSectionHeader />
<Pressable <Pressable
style={styles.mobileCloseButton} style={styles.mobileCloseButton}
onPress={closeSidebar} onPress={closeSidebar}
testID="sidebar-close" testID="sidebar-close"
nativeID="sidebar-close" nativeID="sidebar-close"
accessible accessible
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={labels.closeSidebar} accessibilityLabel={labels.closeSidebar}
hitSlop={8} hitSlop={8}
> >
{({ hovered, pressed }) => ( {({ hovered, pressed }) => (
<X <X
size={theme.iconSize.md} size={theme.iconSize.md}
color={ color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/> />
</View> )}
</Animated.View> </Pressable>
</GestureDetector>
</View> {isInitialLoad ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
statusWorkspacePlacements={statusWorkspacePlacements}
projects={projects}
projectNamesByKey={projectNamesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
/>
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleHome={handleHome}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
</View>
</MobilePanelOverlay>
); );
} }
@@ -1059,17 +900,6 @@ function WorkspacesSectionHeader() {
// avoid the "Unable to find node on an unmounted component" crash when Unistyles // avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages. // tries to patch the native node that Reanimated also manages.
const staticStyles = RNStyleSheet.create({ const staticStyles = RNStyleSheet.create({
backdrop: {
...RNStyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
mobileSidebar: {
position: "absolute" as const,
top: 0,
left: 0,
bottom: 0,
overflow: "hidden" as const,
},
desktopSidebar: { desktopSidebar: {
position: "relative" as const, position: "relative" as const,
}, },

View File

@@ -180,7 +180,7 @@ export function TerminalPane({
return trimmed.length > 0 ? trimmed : undefined; return trimmed.length > 0 ? trimmed : undefined;
}, [settings.monoFontFamily]); }, [settings.monoFontFamily]);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView); const mobileView = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile && mobileView === "agent"; const swipeGesturesEnabled = isMobile && mobileView === "agent";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({ const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({

View File

@@ -1,266 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { selectIsFileExplorerOpen, 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);
// Keeps the overlay displayed long enough for the close animation to finish
// before React hides it.
const OVERLAY_CLOSE_GRACE_MS = 300;
interface ExplorerSidebarAnimationContextValue {
translateX: SharedValue<number>;
backdropOpacity: SharedValue<number>;
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
overlayVisible: boolean;
setOverlayPeek: (peek: boolean) => void;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationContextValue | null>(
null,
);
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
const { startMobilePanelTransition, settleMobilePanel } = useSidebarAnimation();
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const isOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isCompactLayout }),
);
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
const initialTargets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// React owns whether the overlay is displayed at all; the worklet owns its
// motion. See the matching block in sidebar-animation-context.tsx for why
// (Fabric re-applies stale animated props over committed props,
// reanimated#9635).
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
const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView);
const prevWindowWidth = useRef(windowWidth);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
const previousMobileView = prevMobileView.current;
const ownsMobileViewChange =
previousMobileView === "file-explorer" || mobileView === "file-explorer";
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
if (!didStateChange && !didMobileViewChange) {
return;
}
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
const targets = getRightSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
if (isOpen) {
if (isCompactLayout) {
startMobilePanelTransition("file-explorer");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout) {
settleMobilePanel("file-explorer");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
if (isCompactLayout && mobileView === "agent") {
startMobilePanelTransition("agent");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
}, [
isOpen,
mobileView,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
isCompactLayout,
startMobilePanelTransition,
settleMobilePanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
startMobilePanelTransition("file-explorer");
translateX.value = withTiming(
0,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("file-explorer");
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
const animateToClose = useCallback(() => {
"worklet";
startMobilePanelTransition("agent");
translateX.value = withTiming(
windowWidth,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
setOverlayPeek,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
overlayVisible,
isGesturing,
],
);
return (
<ExplorerSidebarAnimationContext.Provider value={value}>
{children}
</ExplorerSidebarAnimationContext.Provider>
);
}
export function useExplorerSidebarAnimation() {
const context = useContext(ExplorerSidebarAnimationContext);
if (!context) {
throw new Error(
"useExplorerSidebarAnimation must be used within ExplorerSidebarAnimationProvider",
);
}
return context;
}
export function useExplorerSidebarAnimationOptional() {
return useContext(ExplorerSidebarAnimationContext);
}

View File

@@ -1,388 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Keyboard, useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import {
getLeftSidebarAnimationTargets,
MOBILE_PANEL_STATE_AGENT,
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
MOBILE_PANEL_TARGET_AGENT,
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
shouldSettleMobilePanelTransition,
shouldSyncSidebarAnimation,
} from "@/utils/sidebar-animation-state";
const ANIMATION_DURATION = 220;
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_LIST = 1;
export const MOBILE_VISUAL_PANEL_FILE_EXPLORER = 2;
interface SidebarAnimationContextValue {
translateX: SharedValue<number>;
backdropOpacity: SharedValue<number>;
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
startMobilePanelTransition: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
settleMobilePanel: (mobileView: "agent" | "agent-list" | "file-explorer") => void;
overlayVisible: boolean;
setOverlayPeek: (peek: boolean) => void;
isGesturing: SharedValue<boolean>;
mobileVisualPanel: SharedValue<number>;
mobilePanelState: SharedValue<number>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
}
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;
}
function getSettledMobilePanelState(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
}
if (mobileView === "file-explorer") {
return MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
}
return MOBILE_PANEL_STATE_AGENT;
}
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const isCompactLayout = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
// Initialize based on current state
const initialTargets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
const translateX = useSharedValue(initialTargets.translateX);
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
const isGesturing = useSharedValue(false);
const mobileVisualPanel = useSharedValue(getMobileVisualPanel(mobileView));
const mobilePanelState = useSharedValue(getSettledMobilePanelState(mobileView));
const mobilePanelTarget = useSharedValue(getMobileVisualPanel(mobileView));
const gestureAnimatingRef = useRef(false);
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// React owns whether the overlay is displayed at all; the worklet owns its
// motion. On Fabric, Reanimated re-applies its own (possibly stale) animated
// props over React's committed props on every commit, so a settled-closed
// overlay can ghost back on screen after a heavy commit (reanimated#9635) —
// no committed transform/opacity value can prevent that. display lives on
// the plain wrapper View that Reanimated never touches, so React's value is
// authoritative: a hidden overlay stays hidden no matter what the animated
// props revert to. overlayPeek shows the overlay during an open gesture
// 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
const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView);
const prevWindowWidth = useRef(windowWidth);
const startMobilePanelTransition = useCallback(
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
"worklet";
if (nextMobileView === "agent-list") {
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT_LIST;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPENING;
return;
}
if (nextMobileView === "file-explorer") {
mobilePanelTarget.value = MOBILE_PANEL_TARGET_FILE_EXPLORER;
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING;
return;
}
mobilePanelTarget.value = MOBILE_PANEL_TARGET_AGENT;
if (mobilePanelState.value === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN) {
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING;
return;
}
if (mobilePanelState.value === MOBILE_PANEL_STATE_AGENT_LIST_OPEN) {
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_CLOSING;
return;
}
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
},
[mobilePanelState, mobilePanelTarget],
);
const settleMobilePanel = useCallback(
(nextMobileView: "agent" | "agent-list" | "file-explorer") => {
"worklet";
if (nextMobileView === "agent-list") {
if (
!shouldSettleMobilePanelTransition(
mobilePanelTarget.value,
MOBILE_PANEL_TARGET_AGENT_LIST,
)
) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
return;
}
if (nextMobileView === "file-explorer") {
if (
!shouldSettleMobilePanelTransition(
mobilePanelTarget.value,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
)
) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
mobilePanelState.value = MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
return;
}
if (!shouldSettleMobilePanelTransition(mobilePanelTarget.value, MOBILE_PANEL_TARGET_AGENT)) {
return;
}
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
mobilePanelState.value = MOBILE_PANEL_STATE_AGENT;
},
[mobileVisualPanel, mobilePanelState, mobilePanelTarget],
);
// Sync animation with store state changes (e.g., backdrop tap, programmatic open/close)
useEffect(() => {
const didStateChange = shouldSyncSidebarAnimation({
previousIsOpen: prevIsOpen.current,
nextIsOpen: isOpen,
previousWindowWidth: prevWindowWidth.current,
nextWindowWidth: windowWidth,
});
const didMobileViewChange = prevMobileView.current !== mobileView;
const previousIsOpen = prevIsOpen.current;
const previousMobileView = prevMobileView.current;
const ownsMobileViewChange = previousMobileView === "agent-list" || mobileView === "agent-list";
prevIsOpen.current = isOpen;
prevMobileView.current = mobileView;
prevWindowWidth.current = windowWidth;
const didOpen = !previousIsOpen && isOpen;
if (!didStateChange && !didMobileViewChange) {
return;
}
if (didOpen && isCompactLayout && isNative) {
Keyboard.dismiss();
}
// Gesture onEnd already started the animation on the UI thread — skip to avoid
// a second competing withTiming that can desync translateX and backdropOpacity
// after a provider remount (e.g. theme change).
if (gestureAnimatingRef.current) {
gestureAnimatingRef.current = false;
return;
}
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
if (isGesturing.value) {
return;
}
const targets = getLeftSidebarAnimationTargets({ isOpen, windowWidth });
if (previousIsOpen !== isOpen) {
if (isOpen) {
if (isCompactLayout) {
startMobilePanelTransition("agent-list");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout) {
settleMobilePanel("agent-list");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
if (isCompactLayout && mobileView === "agent") {
startMobilePanelTransition("agent");
}
translateX.value = withTiming(
targets.translateX,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
return;
}
translateX.value = targets.translateX;
backdropOpacity.value = targets.backdropOpacity;
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
}, [
isOpen,
mobileView,
translateX,
backdropOpacity,
windowWidth,
isGesturing,
isCompactLayout,
mobileVisualPanel,
mobilePanelState,
startMobilePanelTransition,
settleMobilePanel,
]);
const animateToOpen = useCallback(() => {
"worklet";
startMobilePanelTransition("agent-list");
translateX.value = withTiming(
0,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent-list");
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
const animateToClose = useCallback(() => {
"worklet";
startMobilePanelTransition("agent");
translateX.value = withTiming(
-windowWidth,
{
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
},
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
const value = useMemo<SidebarAnimationContextValue>(
() => ({
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
startMobilePanelTransition,
settleMobilePanel,
overlayVisible,
setOverlayPeek,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
startMobilePanelTransition,
settleMobilePanel,
overlayVisible,
isGesturing,
mobileVisualPanel,
mobilePanelState,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
],
);
return (
<SidebarAnimationContext.Provider value={value}>{children}</SidebarAnimationContext.Provider>
);
}
export function useSidebarAnimation() {
const context = useContext(SidebarAnimationContext);
if (!context) {
throw new Error("useSidebarAnimation must be used within SidebarAnimationProvider");
}
return context;
}

View File

@@ -1,147 +0,0 @@
import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { isWeb } from "@/constants/platform";
import { canOpenRightSidebarGesture } from "@/utils/sidebar-animation-state";
interface UseExplorerOpenGestureParams {
enabled: boolean;
onOpen: () => void;
}
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
const {
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
setOverlayPeek,
isGesturing,
gestureAnimatingRef,
openGestureRef,
} = useExplorerSidebarAnimation();
const {
mobilePanelState,
gestureAnimatingRef: mobilePanelGestureAnimatingRef,
openGestureRef: leftOpenGestureRef,
} = useSidebarAnimation();
const touchStartX = useSharedValue(0);
const touchStartY = useSharedValue(0);
const handleGestureOpen = useCallback(() => {
gestureAnimatingRef.current = true;
mobilePanelGestureAnimatingRef.current = true;
onOpen();
}, [onOpen, gestureAnimatingRef, mobilePanelGestureAnimatingRef]);
return useMemo(
() =>
Gesture.Pan()
.withRef(openGestureRef)
.simultaneousWithExternalGesture(leftOpenGestureRef)
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (!touch) {
return;
}
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (!canOpenRightSidebarGesture(mobilePanelState.value, translateX.value, windowWidth)) {
stateManager.fail();
return;
}
// 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) {
stateManager.fail();
return;
}
// Fail quickly on rightward or clearly vertical intent.
if (deltaX >= 10) {
stateManager.fail();
return;
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail();
return;
}
// Activate only on intentional leftward movement.
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate();
}
})
.onStart(() => {
isGesturing.value = true;
// The overlay is display:none while closed; reveal it for the drag.
runOnJS(setOverlayPeek)(true);
})
.onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0.
const newTranslateX = Math.max(
0,
Math.min(windowWidth, windowWidth + event.translationX),
);
translateX.value = newTranslateX;
backdropOpacity.value = interpolate(
newTranslateX,
[windowWidth, 0],
[0, 1],
Extrapolation.CLAMP,
);
})
.onEnd((event) => {
isGesturing.value = false;
const shouldOpenByPosition = translateX.value < (windowWidth * 2) / 3;
const shouldOpenByVelocity = event.velocityX < -500;
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
if (shouldOpen) {
animateToOpen();
runOnJS(handleGestureOpen)();
} else {
animateToClose();
}
})
.onFinalize(() => {
isGesturing.value = false;
runOnJS(setOverlayPeek)(false);
}),
[
enabled,
windowWidth,
translateX,
backdropOpacity,
mobilePanelState,
animateToOpen,
animateToClose,
setOverlayPeek,
isGesturing,
openGestureRef,
leftOpenGestureRef,
handleGestureOpen,
touchStartX,
touchStartY,
],
);
}

View File

@@ -0,0 +1,422 @@
import { useCallback, useMemo } from "react";
import { Gesture } from "react-native-gesture-handler";
import { useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { isWeb } from "@/constants/platform";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { usePanelStore } from "@/stores/panel-store";
import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model";
import { useMobilePanelsRuntime } from "./provider";
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
const PAN_INTENT_FAIL = -1;
const PAN_INTENT_WAIT = 0;
const PAN_INTENT_ACTIVATE = 1;
type PanDirection = -1 | 1;
type PanIntent = typeof PAN_INTENT_ACTIVATE | typeof PAN_INTENT_FAIL | typeof PAN_INTENT_WAIT;
function isCurrentSelection(startedRevision: number): boolean {
return usePanelStore.getState().mobilePanel.revision === startedRevision;
}
function getHorizontalPanIntent(
deltaX: number,
deltaY: number,
direction: PanDirection,
): PanIntent {
"worklet";
const directedDelta = deltaX * direction;
const absDeltaX = Math.abs(deltaX);
const absDeltaY = Math.abs(deltaY);
if (directedDelta <= -10 || (absDeltaY > 10 && absDeltaY > absDeltaX)) {
return PAN_INTENT_FAIL;
}
if (directedDelta >= 15 && absDeltaX > absDeltaY) {
return PAN_INTENT_ACTIVATE;
}
return PAN_INTENT_WAIT;
}
function useGestureState() {
return {
startedRevision: useSharedValue(-1),
touchStartX: useSharedValue(0),
touchStartY: useSharedValue(0),
};
}
function useRevisionCommit(action: () => void) {
return useCallback(
(revision: number) => {
if (isCurrentSelection(revision)) {
action();
}
},
[action],
);
}
export function useOpenAgentListGesture(enabled: boolean) {
const {
beginGesture,
finishGesture,
leftOpenGestureRef,
motionState,
position,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const horizontalScroll = useHorizontalScrollOptional();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const commit = useRevisionCommit(showMobileAgentList);
return useMemo(
() =>
Gesture.Pan()
.withRef(leftOpenGestureRef)
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, 1);
if (
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
horizontalScroll?.isAnyScrolledRight.value ||
(isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
panIntent === PAN_INTENT_FAIL
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
stateManager.activate();
}
})
.onStart(() => {
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" });
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -event.translationX / windowWidth);
})
.onEnd((event, success) => {
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldOpen ? "agent-list" : "agent",
success,
});
if (result) {
scheduleOnRN(commit, result.startedRevision);
}
}),
[
commit,
enabled,
beginGesture,
finishGesture,
horizontalScroll?.isAnyScrolledRight,
leftOpenGestureRef,
motionState,
position,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
windowWidth,
],
);
}
export function useCloseAgentListGesture() {
const {
beginGesture,
finishGesture,
leftCloseGestureRef,
motionState,
position,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const commit = useRevisionCommit(showMobileAgent);
const gesture = useMemo(
() =>
Gesture.Pan()
.withRef(leftCloseGestureRef)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, -1);
if (
!canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) ||
panIntent === PAN_INTENT_FAIL
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
stateManager.activate();
}
})
.onStart(() => {
startedRevision.value = beginGesture({
origin: "agent-list",
preview: "agent-list",
});
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -1 - event.translationX / windowWidth);
})
.onEnd((event, success) => {
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldClose ? "agent" : "agent-list",
success,
});
if (result) {
scheduleOnRN(commit, result.startedRevision);
}
}),
[
beginGesture,
commit,
finishGesture,
leftCloseGestureRef,
motionState,
position,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
windowWidth,
],
);
return { gesture, gestureRef: leftCloseGestureRef };
}
interface OpenFileExplorerGestureOptions {
enabled: boolean;
onOpen: () => void;
}
export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorerGestureOptions) {
const {
beginGesture,
finishGesture,
leftOpenGestureRef,
motionState,
position,
rightOpenGestureRef,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const commit = useRevisionCommit(onOpen);
return useMemo(
() =>
Gesture.Pan()
.withRef(rightOpenGestureRef)
.simultaneousWithExternalGesture(leftOpenGestureRef)
.enabled(enabled)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, -1);
if (
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
(isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
panIntent === PAN_INTENT_FAIL
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
stateManager.activate();
}
})
.onStart(() => {
startedRevision.value = beginGesture({
origin: "agent",
preview: "file-explorer",
});
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -event.translationX / windowWidth);
})
.onEnd((event, success) => {
const shouldOpen = event.translationX < -windowWidth / 3 || event.velocityX < -500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldOpen ? "file-explorer" : "agent",
success,
});
if (result) {
scheduleOnRN(commit, result.startedRevision);
}
}),
[
beginGesture,
commit,
enabled,
finishGesture,
leftOpenGestureRef,
motionState,
position,
rightOpenGestureRef,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
windowWidth,
],
);
}
export function useCloseFileExplorerGesture() {
const {
beginGesture,
finishGesture,
motionState,
position,
rightCloseGestureRef,
updateGesture,
windowWidth,
} = useMobilePanelsRuntime();
const { startedRevision, touchStartX, touchStartY } = useGestureState();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const commit = useRevisionCommit(showMobileAgent);
const gesture = useMemo(
() =>
Gesture.Pan()
.withRef(rightCloseGestureRef)
.manualActivation(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0];
if (touch) {
touchStartX.value = touch.absoluteX;
touchStartY.value = touch.absoluteY;
}
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0];
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail();
return;
}
const deltaX = touch.absoluteX - touchStartX.value;
const deltaY = touch.absoluteY - touchStartY.value;
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
return;
}
const panIntent = getHorizontalPanIntent(deltaX, deltaY, 1);
if (
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
panIntent === PAN_INTENT_FAIL
) {
stateManager.fail();
return;
}
if (panIntent === PAN_INTENT_ACTIVATE) {
stateManager.activate();
}
})
.onStart(() => {
startedRevision.value = beginGesture({
origin: "file-explorer",
preview: "file-explorer",
});
})
.onUpdate((event) => {
updateGesture(startedRevision.value, 1 - event.translationX / windowWidth);
})
.onEnd((event, success) => {
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
const result = finishGesture({
startedRevision: startedRevision.value,
target: shouldClose ? "agent" : "file-explorer",
success,
});
if (result) {
scheduleOnRN(commit, result.startedRevision);
}
}),
[
beginGesture,
commit,
finishGesture,
motionState,
position,
rightCloseGestureRef,
startedRevision,
touchStartX,
touchStartY,
updateGesture,
windowWidth,
],
);
return { gesture, gestureRef: rightCloseGestureRef };
}
export function useFileExplorerCloseGestureRef() {
return useMobilePanelsRuntime().rightCloseGestureRef;
}

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import type { MobilePanelView } from "@/stores/panel-store";
import {
canBeginMobilePanelGesture,
createMobilePanelMotionState,
getMobilePanelFrame,
isMobilePanelGestureCurrent,
transitionMobilePanel,
type MobilePanelCommit,
type MobilePanelEvent,
type MobilePanelMotionState,
} from "./model";
class MobilePanelsScenario {
private nextRevision = 0;
private startedRevision = -1;
private state: MobilePanelMotionState;
readonly commits: MobilePanelCommit[] = [];
constructor(target: MobilePanelView = "agent") {
this.state = createMobilePanelMotionState({ target, revision: this.nextRevision });
}
command(target: MobilePanelView) {
this.nextRevision += 1;
this.dispatch({ type: "command", selection: { target, revision: this.nextRevision } });
return this;
}
beginGesture(origin: MobilePanelView) {
this.dispatch({ type: "gesture.begin", origin });
this.startedRevision = this.state.gesture?.startedRevision ?? -1;
return this;
}
finishGesture(target: MobilePanelView) {
this.dispatch({
type: "gesture.finish",
startedRevision: this.startedRevision,
success: true,
target,
});
return this;
}
cancelGesture() {
this.dispatch({
type: "gesture.finish",
startedRevision: this.startedRevision,
success: false,
target: this.state.target,
});
return this;
}
finishAnimation(target: MobilePanelView, revision = this.state.revision) {
this.dispatch({ type: "animation.finished", revision, target });
return this;
}
snapshot() {
return {
motionTarget: this.state.motionTarget,
revision: this.state.revision,
settledTarget: this.state.settledTarget,
target: this.state.target,
};
}
private dispatch(event: MobilePanelEvent) {
const transition = transitionMobilePanel(this.state, event);
this.state = transition.state;
if (transition.commit) {
this.commits.push(transition.commit);
}
}
}
describe("mobile panel ownership", () => {
it("follows programmatic commands through left, center, and right", () => {
const panels = new MobilePanelsScenario();
panels.command("agent-list").finishAnimation("agent-list");
expect(panels.snapshot()).toEqual({
target: "agent-list",
motionTarget: "agent-list",
settledTarget: "agent-list",
revision: 1,
});
panels.command("agent").command("file-explorer").finishAnimation("file-explorer");
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "file-explorer",
revision: 3,
});
});
it("turns a completed drag into one semantic commit", () => {
const panels = new MobilePanelsScenario();
panels.beginGesture("agent").finishGesture("agent-list");
expect(panels.snapshot()).toEqual({
target: "agent",
motionTarget: "agent-list",
settledTarget: "agent",
revision: 0,
});
expect(panels.commits).toEqual([{ target: "agent-list", startedRevision: 0 }]);
});
it("returns a canceled drag to the latest canonical target", () => {
const panels = new MobilePanelsScenario("agent-list");
panels.beginGesture("agent-list").cancelGesture();
expect(panels.snapshot()).toEqual({
target: "agent-list",
motionTarget: "agent-list",
settledTarget: "agent-list",
revision: 0,
});
expect(panels.commits).toEqual([]);
});
it("makes a command during a drag invalidate the stale gesture finish", () => {
const panels = new MobilePanelsScenario();
panels.beginGesture("agent").command("file-explorer").finishGesture("agent-list");
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "agent",
revision: 1,
});
expect(panels.commits).toEqual([]);
});
it("keeps the latest rapid command and rejects stale animation completion", () => {
const panels = new MobilePanelsScenario();
panels.command("agent-list");
const staleRevision = panels.snapshot().revision;
panels.command("agent").command("file-explorer");
panels.finishAnimation("agent-list", staleRevision);
expect(panels.snapshot()).toEqual({
target: "file-explorer",
motionTarget: "file-explorer",
settledTarget: "agent",
revision: 3,
});
});
it("keeps an activated drag current while blocking a second drag", () => {
const initial = createMobilePanelMotionState({ target: "agent", revision: 7 });
const active = transitionMobilePanel(initial, {
type: "gesture.begin",
origin: "agent",
}).state;
expect(isMobilePanelGestureCurrent(active, 7)).toBe(true);
expect(canBeginMobilePanelGesture(active, "agent", 0)).toBe(false);
});
it("derives both transforms and both backdrops from one normalized position", () => {
expect(getMobilePanelFrame(0.25, 400)).toEqual({
leftBackdropOpacity: 0,
leftTranslateX: -400,
rightBackdropOpacity: 0.25,
rightTranslateX: 300,
});
expect(getMobilePanelFrame(0.25, 800)).toEqual({
leftBackdropOpacity: 0,
leftTranslateX: -800,
rightBackdropOpacity: 0.25,
rightTranslateX: 600,
});
});
});

View File

@@ -0,0 +1,155 @@
import type { MobilePanelSelection, MobilePanelView } from "@/stores/panel-store";
interface MobilePanelGesture {
startedRevision: number;
}
export interface MobilePanelMotionState extends MobilePanelSelection {
gesture: MobilePanelGesture | null;
motionTarget: MobilePanelView;
settledTarget: MobilePanelView;
}
export interface MobilePanelCommit {
startedRevision: number;
target: MobilePanelView;
}
export interface MobilePanelTransition {
animationTarget?: MobilePanelView;
commit?: MobilePanelCommit;
state: MobilePanelMotionState;
}
export type MobilePanelEvent =
| { type: "command"; selection: MobilePanelSelection }
| { type: "gesture.begin"; origin: MobilePanelView }
| {
type: "gesture.finish";
startedRevision: number;
success: boolean;
target: MobilePanelView;
}
| { type: "animation.finished"; revision: number; target: MobilePanelView };
export function createMobilePanelMotionState(
selection: MobilePanelSelection,
): MobilePanelMotionState {
return {
...selection,
gesture: null,
motionTarget: selection.target,
settledTarget: selection.target,
};
}
export function transitionMobilePanel(
state: MobilePanelMotionState,
event: MobilePanelEvent,
): MobilePanelTransition {
"worklet";
if (event.type === "command") {
if (event.selection.revision <= state.revision) {
return { state };
}
return {
animationTarget: event.selection.target,
state: {
...state,
...event.selection,
gesture: null,
motionTarget: event.selection.target,
},
};
}
if (event.type === "gesture.begin") {
if (
state.gesture ||
state.target !== event.origin ||
state.motionTarget !== event.origin ||
state.settledTarget !== event.origin
) {
return { state };
}
return {
state: {
...state,
gesture: {
startedRevision: state.revision,
},
},
};
}
if (event.type === "gesture.finish") {
const ownsCurrentRevision = state.gesture?.startedRevision === state.revision;
const ownsFinish = state.gesture?.startedRevision === event.startedRevision;
if (!ownsCurrentRevision || !ownsFinish || !state.gesture) {
return { state };
}
const startedRevision = state.gesture.startedRevision;
const target = event.success ? event.target : state.target;
return {
animationTarget: target,
commit: event.success && target !== state.target ? { startedRevision, target } : undefined,
state: {
...state,
gesture: null,
motionTarget: target,
},
};
}
const ownsCurrentRevision = event.revision === state.revision;
const isCanonicalTarget = event.target === state.target;
const isCurrentMotionTarget = event.target === state.motionTarget;
if (!ownsCurrentRevision || !isCanonicalTarget || !isCurrentMotionTarget) {
return { state };
}
return {
state: { ...state, settledTarget: event.target },
};
}
export function getMobilePanelAnchor(panel: MobilePanelView): number {
"worklet";
if (panel === "agent-list") {
return -1;
}
if (panel === "file-explorer") {
return 1;
}
return 0;
}
export function canBeginMobilePanelGesture(
state: MobilePanelMotionState,
origin: MobilePanelView,
position: number,
): boolean {
"worklet";
const isCanonical = state.target === origin;
const isMotionSettled = state.motionTarget === origin && state.settledTarget === origin;
const isAtOrigin = Math.abs(position - getMobilePanelAnchor(origin)) <= 0.002;
return !state.gesture && isCanonical && isMotionSettled && isAtOrigin;
}
export function isMobilePanelGestureCurrent(
state: MobilePanelMotionState,
startedRevision: number,
): boolean {
"worklet";
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
}
export function getMobilePanelFrame(position: number, width: number) {
"worklet";
const clampedPosition = Math.max(-1, Math.min(1, position));
return {
leftBackdropOpacity: Math.max(0, -clampedPosition),
leftTranslateX: -Math.min(1, clampedPosition + 1) * width,
rightBackdropOpacity: Math.max(0, clampedPosition),
rightTranslateX: Math.min(1, 1 - clampedPosition) * width,
};
}

View File

@@ -0,0 +1,115 @@
import { useMemo, type ComponentProps, type ReactNode } from "react";
import { Pressable, StyleSheet, View } from "react-native";
import { GestureDetector, type GestureType } from "react-native-gesture-handler";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { isWeb } from "@/constants/platform";
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { getMobilePanelFrame } from "./model";
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
type OverlayPanel = Exclude<MobilePanelView, "agent">;
interface MobilePanelOverlayProps {
children: ReactNode;
closeGesture: GestureType;
panel: OverlayPanel;
panelStyle?: ComponentProps<typeof Animated.View>["style"];
}
export function MobilePanelOverlay({
children,
closeGesture,
panel,
panelStyle,
}: MobilePanelOverlayProps) {
const { position, windowWidth } = useMobilePanelsRuntime();
const target = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const isOpen = target === panel;
const isPresented = useIsMobilePanelPresented(panel);
const isLeft = panel === "agent-list";
const sidebarAnimatedStyle = useAnimatedStyle(() => {
const frame = getMobilePanelFrame(position.value, windowWidth);
return {
transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }],
};
}, [isLeft, windowWidth]);
const backdropAnimatedStyle = useAnimatedStyle(() => {
const frame = getMobilePanelFrame(position.value, windowWidth);
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
}, [isLeft, windowWidth]);
const overlayStyle = useMemo(
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
[isPresented],
);
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
const backdropStyle = useMemo(
() => [styles.backdrop, backdropAnimatedStyle],
[backdropAnimatedStyle],
);
const combinedPanelStyle = useMemo(
() => [
styles.panel,
positionedPanelStyle,
{ width: windowWidth },
panelStyle,
sidebarAnimatedStyle,
],
[panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth],
);
let overlayPointerEvents: "auto" | "box-none" | "none";
if (!isWeb) {
overlayPointerEvents = "box-none";
} else {
overlayPointerEvents = isOpen ? "auto" : "none";
}
return (
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Pressable
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onPress={showMobileAgent}
pointerEvents={isOpen ? "auto" : "none"}
style={StyleSheet.absoluteFillObject}
testID={`${panel}-backdrop`}
>
<Animated.View pointerEvents="none" style={backdropStyle} />
</Pressable>
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
{children}
</Animated.View>
</GestureDetector>
</View>
);
}
// Reanimated owns only these static native styles and the derived transform.
// Theme values stay inline at call sites, avoiding Unistyles patching the same
// native node after Fabric commits.
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
panel: {
position: "absolute",
top: 0,
bottom: 0,
overflow: "hidden",
},
leftPanel: {
left: 0,
},
rightPanel: {
right: 0,
},
});

View File

@@ -0,0 +1,260 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { Keyboard, useWindowDimensions } from "react-native";
import type { GestureType } from "react-native-gesture-handler";
import {
cancelAnimation,
Easing,
useSharedValue,
withTiming,
type SharedValue,
} from "react-native-reanimated";
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
import { isNative } from "@/constants/platform";
import {
usePanelStore,
type MobilePanelSelection,
type MobilePanelView,
} from "@/stores/panel-store";
import {
canBeginMobilePanelGesture,
createMobilePanelMotionState,
getMobilePanelAnchor,
isMobilePanelGestureCurrent,
transitionMobilePanel,
type MobilePanelCommit,
type MobilePanelMotionState,
type MobilePanelTransition,
} from "./model";
const ANIMATION_DURATION = 220;
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
const LEFT_PANEL_MASK = 1;
const RIGHT_PANEL_MASK = 2;
function getPanelMask(panel: MobilePanelView): number {
if (panel === "agent-list") {
return LEFT_PANEL_MASK;
}
if (panel === "file-explorer") {
return RIGHT_PANEL_MASK;
}
return 0;
}
interface MobilePanelsRuntime {
beginGesture: (input: BeginGestureInput) => number;
finishGesture: (input: FinishGestureInput) => MobilePanelCommit | null;
leftCloseGestureRef: RefObject<GestureType | undefined>;
leftOpenGestureRef: RefObject<GestureType | undefined>;
motionState: SharedValue<MobilePanelMotionState>;
position: SharedValue<number>;
rightCloseGestureRef: RefObject<GestureType | undefined>;
rightOpenGestureRef: RefObject<GestureType | undefined>;
updateGesture: (startedRevision: number, nextPosition: number) => boolean;
windowWidth: number;
}
interface BeginGestureInput {
origin: MobilePanelView;
preview: MobilePanelView;
}
interface FinishGestureInput {
startedRevision: number;
success: boolean;
target: MobilePanelView;
}
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
const MobilePanelPresentationContext = createContext(0);
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
const initialSelection = useRef(usePanelStore.getState().mobilePanel).current;
const position = useSharedValue(getMobilePanelAnchor(initialSelection.target));
const motionState = useSharedValue(createMobilePanelMotionState(initialSelection));
const leftOpenGestureRef = useRef<GestureType | undefined>(undefined);
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target));
const presentPanel = useCallback((panel: MobilePanelView) => {
const mask = getPanelMask(panel);
if (mask) {
setPresentedPanels((current) => current | mask);
}
}, []);
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
const selection = usePanelStore.getState().mobilePanel;
if (selection.revision !== revision || selection.target !== panel) {
return;
}
setPresentedPanels(getPanelMask(panel));
}, []);
const animateTransition = useCallback(
(transition: MobilePanelTransition) => {
"worklet";
if (!transition.animationTarget) {
return;
}
const target = transition.animationTarget;
const revision = transition.state.revision;
position.value = withTiming(
getMobilePanelAnchor(target),
{ duration: ANIMATION_DURATION, easing: ANIMATION_EASING },
(finished) => {
if (!finished) {
return;
}
const currentState = motionState.value;
const settled = transitionMobilePanel(currentState, {
type: "animation.finished",
revision,
target,
});
if (settled.state === currentState) {
return;
}
motionState.value = settled.state;
scheduleOnRN(settlePresentation, target, revision);
},
);
},
[motionState, position, settlePresentation],
);
const applySelection = useCallback(
(selection: MobilePanelSelection) => {
"worklet";
const currentState = motionState.value;
const transition = transitionMobilePanel(currentState, {
type: "command",
selection,
});
if (transition.state === currentState) {
return;
}
motionState.value = transition.state;
animateTransition(transition);
},
[animateTransition, motionState],
);
useEffect(() => {
return usePanelStore.subscribe((state, previousState) => {
const selection = state.mobilePanel;
if (selection === previousState.mobilePanel) {
return;
}
if (selection.target !== "agent") {
presentPanel(selection.target);
if (isNative) {
Keyboard.dismiss();
}
}
scheduleOnUI(applySelection, selection);
});
}, [applySelection, presentPanel]);
const beginGesture = useCallback(
({ origin, preview }: BeginGestureInput): number => {
"worklet";
const currentState = motionState.value;
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
return -1;
}
const transition = transitionMobilePanel(currentState, {
type: "gesture.begin",
origin,
});
motionState.value = transition.state;
cancelAnimation(position);
scheduleOnRN(presentPanel, preview);
return transition.state.gesture?.startedRevision ?? -1;
},
[motionState, position, presentPanel],
);
const updateGesture = useCallback(
(startedRevision: number, nextPosition: number): boolean => {
"worklet";
if (!isMobilePanelGestureCurrent(motionState.value, startedRevision)) {
return false;
}
position.value = Math.max(-1, Math.min(1, nextPosition));
return true;
},
[motionState, position],
);
const finishGesture = useCallback(
({ startedRevision, target, success }: FinishGestureInput): MobilePanelCommit | null => {
"worklet";
const currentState = motionState.value;
const transition = transitionMobilePanel(currentState, {
type: "gesture.finish",
startedRevision,
success,
target,
});
if (transition.state === currentState) {
return null;
}
motionState.value = transition.state;
animateTransition(transition);
return transition.commit ?? null;
},
[animateTransition, motionState],
);
const value = useMemo<MobilePanelsRuntime>(
() => ({
beginGesture,
finishGesture,
leftCloseGestureRef,
leftOpenGestureRef,
motionState,
position,
rightCloseGestureRef,
rightOpenGestureRef,
updateGesture,
windowWidth,
}),
[beginGesture, finishGesture, motionState, position, updateGesture, windowWidth],
);
return (
<MobilePanelsContext.Provider value={value}>
<MobilePanelPresentationContext.Provider value={presentedPanels}>
{children}
</MobilePanelPresentationContext.Provider>
</MobilePanelsContext.Provider>
);
}
/** Internal to the mobile-panels module. Callers use gesture and presentation adapters. */
export function useMobilePanelsRuntime(): MobilePanelsRuntime {
const context = useContext(MobilePanelsContext);
if (!context) {
throw new Error("useMobilePanelsRuntime must be used within MobilePanelsProvider");
}
return context;
}
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
const presentedPanels = useContext(MobilePanelPresentationContext);
return (presentedPanels & getPanelMask(panel)) !== 0;
}

View File

@@ -26,10 +26,12 @@ import {
migratePanelState, migratePanelState,
selectIsAgentListOpen, selectIsAgentListOpen,
selectIsFileExplorerOpen, selectIsFileExplorerOpen,
setMobilePanelTarget,
selectPanelVisibility, selectPanelVisibility,
type DesktopSidebarState, type DesktopSidebarState,
type ExplorerPanelIntent, type ExplorerPanelIntent,
type MobilePanelView, type MobilePanelView,
type MobilePanelSelection,
type PanelLayoutInput, type PanelLayoutInput,
type PanelVisibilityState, type PanelVisibilityState,
type SortOption, type SortOption,
@@ -41,6 +43,7 @@ export type {
DesktopSidebarState, DesktopSidebarState,
ExplorerPanelIntent, ExplorerPanelIntent,
MobilePanelView, MobilePanelView,
MobilePanelSelection,
PanelLayoutInput, PanelLayoutInput,
PanelVisibilityState, PanelVisibilityState,
SortOption, SortOption,
@@ -61,8 +64,8 @@ export {
}; };
export interface PanelState { export interface PanelState {
// Mobile: which panel is currently shown // Mobile: React's durable target plus the generation that owns it.
mobileView: MobilePanelView; mobilePanel: MobilePanelSelection;
// Desktop: independent sidebar toggles // Desktop: independent sidebar toggles
desktop: DesktopSidebarState; desktop: DesktopSidebarState;
@@ -114,11 +117,19 @@ export interface PanelState {
const DEFAULT_DESKTOP_OPEN = isWeb; const DEFAULT_DESKTOP_OPEN = isWeb;
function setMobilePanelTargetPatch(
state: PanelState,
target: MobilePanelView,
): PanelState | Pick<PanelState, "mobilePanel"> {
const mobilePanel = setMobilePanelTarget(state.mobilePanel, target);
return mobilePanel === state.mobilePanel ? state : { mobilePanel };
}
export const usePanelStore = create<PanelState>()( export const usePanelStore = create<PanelState>()(
persist( persist(
(set) => ({ (set) => ({
// Mobile always starts at agent view // Mobile always starts at agent view
mobileView: "agent", mobilePanel: { target: "agent", revision: 0 },
// Desktop defaults based on platform // Desktop defaults based on platform
desktop: { desktop: {
@@ -144,26 +155,17 @@ export const usePanelStore = create<PanelState>()(
desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled }, desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled },
})), })),
showMobileAgent: () => showMobileAgent: () => set((state) => setMobilePanelTargetPatch(state, "agent")),
set((state) => {
if (state.mobileView === "agent") {
return state;
}
return { mobileView: "agent" as const };
}),
showMobileAgentList: () => showMobileAgentList: () => set((state) => setMobilePanelTargetPatch(state, "agent-list")),
set((state) => {
if (state.mobileView === "agent-list") {
return state;
}
return { mobileView: "agent-list" as const };
}),
toggleMobileAgentList: () => toggleMobileAgentList: () =>
set((state) => ({ set((state) =>
mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list", setMobilePanelTargetPatch(
})), state,
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
),
),
openDesktopAgentList: () => openDesktopAgentList: () =>
set((state) => { set((state) => {
@@ -197,9 +199,7 @@ export const usePanelStore = create<PanelState>()(
openAgentListForLayout: ({ isCompact }) => openAgentListForLayout: ({ isCompact }) =>
set((state) => { set((state) => {
if (isCompact) { if (isCompact) {
return state.mobileView === "agent-list" return setMobilePanelTargetPatch(state, "agent-list");
? state
: { mobileView: "agent-list" as const };
} }
return state.desktop.agentListOpen return state.desktop.agentListOpen
? state ? state
@@ -209,7 +209,7 @@ export const usePanelStore = create<PanelState>()(
closeAgentListForLayout: ({ isCompact }) => closeAgentListForLayout: ({ isCompact }) =>
set((state) => { set((state) => {
if (isCompact) { if (isCompact) {
return state.mobileView === "agent" ? state : { mobileView: "agent" as const }; return setMobilePanelTargetPatch(state, "agent");
} }
return state.desktop.agentListOpen return state.desktop.agentListOpen
? { desktop: { ...state.desktop, agentListOpen: false } } ? { desktop: { ...state.desktop, agentListOpen: false } }
@@ -219,7 +219,10 @@ export const usePanelStore = create<PanelState>()(
toggleAgentListForLayout: ({ isCompact }) => toggleAgentListForLayout: ({ isCompact }) =>
set((state) => { set((state) => {
if (isCompact) { if (isCompact) {
return { mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list" }; return setMobilePanelTargetPatch(
state,
state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
);
} }
return { return {
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen }, desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
@@ -295,7 +298,6 @@ export const usePanelStore = create<PanelState>()(
migrate: (persistedState, version) => migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState, migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
partialize: (state) => ({ partialize: (state) => ({
mobileView: state.mobileView,
desktop: state.desktop, desktop: state.desktop,
explorerTab: state.explorerTab, explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout, explorerTabByCheckout: state.explorerTabByCheckout,
@@ -315,7 +317,7 @@ export const usePanelStore = create<PanelState>()(
/** /**
* Hook that provides platform-aware panel state. * Hook that provides platform-aware panel state.
* *
* On mobile, uses the state machine (mobileView). * On mobile, uses the revisioned mobile panel target.
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen). * On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
* *
* @param isMobile - Whether the current breakpoint is mobile * @param isMobile - Whether the current breakpoint is mobile

View File

@@ -10,13 +10,14 @@ import {
migratePanelState, migratePanelState,
selectIsAgentListOpen, selectIsAgentListOpen,
selectIsFileExplorerOpen, selectIsFileExplorerOpen,
setMobilePanelTarget,
selectPanelVisibility, selectPanelVisibility,
type PanelCoreState, type PanelCoreState,
} from "./state"; } from "./state";
function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState { function makePanelState(overrides: Partial<PanelCoreState> = {}): PanelCoreState {
return { return {
mobileView: "agent", mobilePanel: { target: "agent", revision: 0 },
desktop: { desktop: {
agentListOpen: false, agentListOpen: false,
fileExplorerOpen: false, fileExplorerOpen: false,
@@ -117,12 +118,33 @@ describe("panel-store migration", () => {
expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] }); expect(state.diffCollapsedFoldersByWorkspace).toEqual({ ws: ["src/app"] });
}); });
it("drops persisted compact panel state so cold starts return to content", () => {
const state = migratePanelState(
{ mobileView: "agent-list", mobilePanel: { target: "file-explorer", revision: 42 } },
11,
{ isWeb: false },
);
expect(state.mobileView).toBeUndefined();
expect(state.mobilePanel).toBeUndefined();
});
}); });
describe("panel-store visibility selectors", () => { describe("panel-store visibility selectors", () => {
it("uses mobileView for compact layout visibility", () => { it("increments the mobile panel revision only when the target changes", () => {
const initial = { target: "agent" as const, revision: 4 };
expect(setMobilePanelTarget(initial, "agent")).toBe(initial);
expect(setMobilePanelTarget(initial, "agent-list")).toEqual({
target: "agent-list",
revision: 5,
});
});
it("uses the mobile panel target for compact layout visibility", () => {
const state = makePanelState({ const state = makePanelState({
mobileView: "file-explorer", mobilePanel: { target: "file-explorer", revision: 1 },
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false }, desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
}); });
@@ -136,7 +158,7 @@ describe("panel-store visibility selectors", () => {
it("uses desktop flags for expanded layout visibility", () => { it("uses desktop flags for expanded layout visibility", () => {
const state = makePanelState({ const state = makePanelState({
mobileView: "file-explorer", mobilePanel: { target: "file-explorer", revision: 1 },
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false }, desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
}); });
@@ -160,7 +182,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout }); const patch = buildOpenFileExplorerPatch(state, { isCompact: true, checkout });
expect(patch.mobileView).toBe("file-explorer"); expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 });
expect(patch.desktop).toBeUndefined(); expect(patch.desktop).toBeUndefined();
expect(patch.explorerTab).toBe("files"); expect(patch.explorerTab).toBe("files");
}); });
@@ -175,7 +197,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout }); const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
expect(patch.mobileView).toBeUndefined(); expect(patch.mobilePanel).toBeUndefined();
expect(patch.desktop?.fileExplorerOpen).toBe(true); expect(patch.desktop?.fileExplorerOpen).toBe(true);
expect(patch.explorerTab).toBe("files"); expect(patch.explorerTab).toBe("files");
}); });

View File

@@ -8,6 +8,11 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
export interface MobilePanelSelection {
target: MobilePanelView;
revision: number;
}
export interface DesktopSidebarState { export interface DesktopSidebarState {
agentListOpen: boolean; agentListOpen: boolean;
fileExplorerOpen: boolean; fileExplorerOpen: boolean;
@@ -43,7 +48,7 @@ export interface ExplorerPanelIntent extends PanelLayoutInput {
} }
export interface PanelCoreState { export interface PanelCoreState {
mobileView: MobilePanelView; mobilePanel: MobilePanelSelection;
desktop: DesktopSidebarState; desktop: DesktopSidebarState;
explorerTab: ExplorerTab; explorerTab: ExplorerTab;
explorerTabByCheckout: Record<string, ExplorerTab>; explorerTabByCheckout: Record<string, ExplorerTab>;
@@ -74,8 +79,8 @@ export function selectPanelVisibility(
): PanelVisibilityState { ): PanelVisibilityState {
if (input.isCompact) { if (input.isCompact) {
return { return {
isAgentListOpen: state.mobileView === "agent-list", isAgentListOpen: state.mobilePanel.target === "agent-list",
isFileExplorerOpen: state.mobileView === "file-explorer", isFileExplorerOpen: state.mobilePanel.target === "file-explorer",
}; };
} }
return { return {
@@ -92,6 +97,16 @@ export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayo
return selectPanelVisibility(state, input).isFileExplorerOpen; return selectPanelVisibility(state, input).isFileExplorerOpen;
} }
export function setMobilePanelTarget(
selection: MobilePanelSelection,
target: MobilePanelView,
): MobilePanelSelection {
if (selection.target === target) {
return selection;
}
return { target, revision: selection.revision + 1 };
}
function resolveExplorerTabFromCheckout( function resolveExplorerTabFromCheckout(
state: PanelCoreState, state: PanelCoreState,
checkout: ExplorerCheckoutContext, checkout: ExplorerCheckoutContext,
@@ -105,7 +120,7 @@ function resolveExplorerTabFromCheckout(
} }
export interface OpenFileExplorerPatch { export interface OpenFileExplorerPatch {
mobileView?: MobilePanelView; mobilePanel?: MobilePanelSelection;
desktop?: DesktopSidebarState; desktop?: DesktopSidebarState;
explorerTab: ExplorerTab; explorerTab: ExplorerTab;
} }
@@ -117,7 +132,7 @@ export function buildOpenFileExplorerPatch(
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout); const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
if (input.isCompact) { if (input.isCompact) {
return { return {
mobileView: "file-explorer", mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
explorerTab: resolvedTab, explorerTab: resolvedTab,
}; };
} }
@@ -129,7 +144,7 @@ export function buildOpenFileExplorerPatch(
export type ToggleFileExplorerPatch = export type ToggleFileExplorerPatch =
| OpenFileExplorerPatch | OpenFileExplorerPatch
| { mobileView: MobilePanelView } | { mobilePanel: MobilePanelSelection }
| { desktop: DesktopSidebarState }; | { desktop: DesktopSidebarState };
export function buildToggleFileExplorerPatch( export function buildToggleFileExplorerPatch(
@@ -141,7 +156,7 @@ export function buildToggleFileExplorerPatch(
return buildOpenFileExplorerPatch(state, input); return buildOpenFileExplorerPatch(state, input);
} }
if (input.isCompact) { if (input.isCompact) {
return { mobileView: "agent" }; return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
} }
return { desktop: { ...state.desktop, fileExplorerOpen: false } }; return { desktop: { ...state.desktop, fileExplorerOpen: false } };
} }
@@ -255,6 +270,12 @@ export function migratePanelState(
if (typeof state.explorerShowHiddenFiles !== "boolean") { if (typeof state.explorerShowHiddenFiles !== "boolean") {
state.explorerShowHiddenFiles = true; state.explorerShowHiddenFiles = true;
} }
if (version < 12) {
// Compact panel position is transient UI state. Cold starts always begin
// at content, regardless of what an older version persisted.
delete state.mobileView;
delete state.mobilePanel;
}
return state; return state;
} }

View File

@@ -1,111 +0,0 @@
import { describe, expect, it } from "vitest";
import {
canCloseLeftSidebarGesture,
canCloseRightSidebarGesture,
canOpenLeftSidebarGesture,
canOpenRightSidebarGesture,
getLeftSidebarAnimationTargets,
getRightSidebarAnimationTargets,
MOBILE_PANEL_STATE_AGENT,
MOBILE_PANEL_STATE_AGENT_LIST_CLOSING,
MOBILE_PANEL_STATE_AGENT_LIST_OPEN,
MOBILE_PANEL_STATE_AGENT_LIST_OPENING,
MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN,
MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING,
MOBILE_PANEL_TARGET_AGENT,
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
shouldSettleMobilePanelTransition,
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,
});
});
it("allows the left open gesture only after the app is settled on the agent panel", () => {
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -430, 430)).toBe(true);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT, -240, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING, -430, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING, -430, 430)).toBe(false);
expect(canOpenLeftSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN, -430, 430)).toBe(false);
});
it("allows the left close gesture only while the left sidebar is settled open", () => {
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN)).toBe(true);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPENING)).toBe(false);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_CLOSING)).toBe(false);
expect(canCloseLeftSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
});
it("allows the right open gesture only after the app is settled on the agent panel", () => {
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 430, 430)).toBe(true);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT, 240, 430)).toBe(false);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING, 430, 430)).toBe(
false,
);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING, 430, 430)).toBe(
false,
);
expect(canOpenRightSidebarGesture(MOBILE_PANEL_STATE_AGENT_LIST_OPEN, 430, 430)).toBe(false);
});
it("allows the right close gesture only while the right sidebar is settled open", () => {
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN)).toBe(true);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING)).toBe(false);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING)).toBe(false);
expect(canCloseRightSidebarGesture(MOBILE_PANEL_STATE_AGENT)).toBe(false);
});
it("rejects stale settle callbacks from a panel that is no longer the destination", () => {
expect(
shouldSettleMobilePanelTransition(
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_AGENT_LIST,
),
).toBe(true);
expect(
shouldSettleMobilePanelTransition(MOBILE_PANEL_TARGET_AGENT_LIST, MOBILE_PANEL_TARGET_AGENT),
).toBe(false);
expect(
shouldSettleMobilePanelTransition(
MOBILE_PANEL_TARGET_AGENT_LIST,
MOBILE_PANEL_TARGET_FILE_EXPLORER,
),
).toBe(false);
});
});

View File

@@ -1,96 +0,0 @@
interface SidebarAnimationSyncInput {
previousIsOpen: boolean;
nextIsOpen: boolean;
previousWindowWidth: number;
nextWindowWidth: number;
}
interface SidebarAnimationTargetInput {
isOpen: boolean;
windowWidth: number;
}
interface SidebarAnimationTargets {
translateX: number;
backdropOpacity: number;
}
export const MOBILE_PANEL_STATE_AGENT = 0;
export const MOBILE_PANEL_STATE_AGENT_LIST_OPENING = 1;
export const MOBILE_PANEL_STATE_AGENT_LIST_OPEN = 2;
export const MOBILE_PANEL_STATE_AGENT_LIST_CLOSING = 3;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPENING = 4;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN = 5;
export const MOBILE_PANEL_STATE_FILE_EXPLORER_CLOSING = 6;
export const MOBILE_PANEL_TARGET_AGENT = 0;
export const MOBILE_PANEL_TARGET_AGENT_LIST = 1;
export const MOBILE_PANEL_TARGET_FILE_EXPLORER = 2;
const CLOSED_POSITION_TOLERANCE = 1;
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,
};
}
export function canOpenLeftSidebarGesture(
mobilePanelState: number,
translateX: number,
windowWidth: number,
): boolean {
"worklet";
return (
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
translateX <= -windowWidth + CLOSED_POSITION_TOLERANCE
);
}
export function canCloseLeftSidebarGesture(mobilePanelState: number): boolean {
"worklet";
return mobilePanelState === MOBILE_PANEL_STATE_AGENT_LIST_OPEN;
}
export function canOpenRightSidebarGesture(
mobilePanelState: number,
translateX: number,
windowWidth: number,
): boolean {
"worklet";
return (
mobilePanelState === MOBILE_PANEL_STATE_AGENT &&
translateX >= windowWidth - CLOSED_POSITION_TOLERANCE
);
}
export function canCloseRightSidebarGesture(mobilePanelState: number): boolean {
"worklet";
return mobilePanelState === MOBILE_PANEL_STATE_FILE_EXPLORER_OPEN;
}
export function shouldSettleMobilePanelTransition(
activeTarget: number,
settledTarget: number,
): boolean {
"worklet";
return activeTarget === settledTarget;
}