diff --git a/CLAUDE.md b/CLAUDE.md
index 61f6358fe..a5abd4e0c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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/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-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/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 |
diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md
new file mode 100644
index 000000000..7f590312c
--- /dev/null
+++ b/docs/mobile-panels.md
@@ -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.
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 12e179838..f7d07f4e0 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -23,9 +23,8 @@ import {
useSyncExternalStore,
} from "react";
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 { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
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 { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
-import {
- HorizontalScrollProvider,
- useHorizontalScrollOptional,
-} from "@/contexts/horizontal-scroll-context";
+import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-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 { ToastProvider } from "@/contexts/toast-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 { useAppSettings } from "@/hooks/use-settings";
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 { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
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 type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
-import { canOpenLeftSidebarGesture } from "@/utils/sidebar-animation-state";
import {
buildOpenProjectRoute,
parseHostAgentRouteFromPathname,
@@ -405,7 +397,6 @@ function QueryProvider({ children }: { children: ReactNode }) {
const rowStyle = { flex: 1, flexDirection: "row" } 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";
interface AppContainerProps {
@@ -481,11 +472,9 @@ function AppContainer({
)}
{isCompactLayout && chromeEnabled ? (
-
-
- {children}
-
-
+
+ {children}
+
) : (
{children}
)}
@@ -526,127 +515,7 @@ function MobileGestureWrapper({
children: ReactNode;
chromeEnabled: boolean;
}) {
- const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
- 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,
- ],
- );
+ const openGesture = useOpenAgentListGesture(chromeEnabled);
return (
@@ -974,7 +843,7 @@ function WorkspaceRouteNavigationBridge() {
function AppShell() {
return (
-
+
@@ -982,7 +851,7 @@ function AppShell() {
-
+
);
}
diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx
index 5e79d4a0a..f51f827fa 100644
--- a/packages/app/src/components/compact-explorer-sidebar-host.tsx
+++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx
@@ -4,7 +4,7 @@ import { GestureDetector } from "react-native-gesture-handler";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
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 { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -29,7 +29,7 @@ function CompactExplorerOpenGestureSurface({
enabled,
onOpenExplorer,
}: CompactExplorerOpenGestureSurfaceProps) {
- const explorerOpenGesture = useExplorerOpenGesture({
+ const explorerOpenGesture = useOpenFileExplorerGesture({
enabled,
onOpen: onOpenExplorer,
});
diff --git a/packages/app/src/components/diff-scroll.tsx b/packages/app/src/components/diff-scroll.tsx
index ca608b23e..49110c3c7 100644
--- a/packages/app/src/components/diff-scroll.tsx
+++ b/packages/app/src/components/diff-scroll.tsx
@@ -8,7 +8,7 @@ import {
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
-import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context";
+import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
interface DiffScrollProps {
children: React.ReactNode;
@@ -30,9 +30,7 @@ export function DiffScroll({
const scrollId = useId();
const scrollViewRef = useRef(null);
- // Get the close gesture ref from animation context (may not be available outside sidebar)
- const animation = useExplorerSidebarAnimationOptional();
- const closeGestureRef = animation?.closeGestureRef;
+ const closeGestureRef = useFileExplorerCloseGestureRef();
// Register/unregister scroll offset tracking
useEffect(() => {
diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx
index 0e73d89f3..7ec31ff2e 100644
--- a/packages/app/src/components/explorer-sidebar.tsx
+++ b/packages/app/src/components/explorer-sidebar.tsx
@@ -29,10 +29,9 @@ import {
MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab,
} 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 { 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 { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
@@ -91,25 +90,11 @@ export function CompactExplorerSidebar({
workspaceRoot,
isGit,
});
- const closeTouchStartX = useSharedValue(0);
- const closeTouchStartY = useSharedValue(0);
- const { mobilePanelState, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
- useSidebarAnimation();
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: true,
});
- const {
- translateX,
- backdropOpacity,
- windowWidth,
- animateToOpen,
- animateToClose,
- overlayVisible,
- isGesturing,
- gestureAnimatingRef,
- closeGestureRef,
- } = useExplorerSidebarAnimation();
+ const { gesture: closeGesture } = useCloseFileExplorerGesture();
const handleClose = useCallback(
(reason: string) => {
@@ -122,184 +107,38 @@ export function CompactExplorerSidebar({
[isOpen, showMobileAgent],
);
- const handleCloseFromGesture = useCallback(() => {
- gestureAnimatingRef.current = true;
- mobilePanelGestureAnimatingRef.current = true;
- showMobileAgent();
- }, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
-
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(
() => [
- explorerStaticStyles.mobileSidebar,
{
- width: windowWidth,
paddingTop: insets.top,
backgroundColor: theme.colors.surfaceSidebar,
},
- sidebarAnimatedStyle,
- mobileKeyboardInsetStyle,
- ],
- [
- windowWidth,
- insets.top,
- theme.colors.surfaceSidebar,
- sidebarAnimatedStyle,
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 (
-
-
-
-
-
-
-
-
-
+
+
+
);
}
@@ -604,17 +443,6 @@ function PrTabContent({
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
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: {
position: "relative" as const,
},
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 68375a53a..b55f9c56e 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -21,13 +21,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
-import Animated, {
- Extrapolation,
- interpolate,
- runOnJS,
- useAnimatedStyle,
- useSharedValue,
-} from "react-native-reanimated";
+import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
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 { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
-import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -62,7 +55,8 @@ import {
usePanelStore,
} from "@/stores/panel-store";
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 {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
@@ -123,7 +117,6 @@ interface SidebarLabels {
interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
- isOpen: boolean;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void;
@@ -278,7 +271,6 @@ export const LeftSidebar = memo(function LeftSidebar({
{...sharedProps}
insetsTop={insets.top}
insetsBottom={insets.bottom}
- isOpen={isOpen}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleHome={handleHomeMobile}
@@ -576,7 +568,6 @@ function MobileSidebar({
handleOpenHostSettings,
insetsTop,
insetsBottom,
- isOpen,
closeSidebar,
handleViewMoreNavigate,
handleViewSchedulesNavigate,
@@ -584,262 +575,112 @@ function MobileSidebar({
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
- const {
- 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 { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const handleViewMore = useCallback(() => {
- translateX.value = -windowWidth;
- backdropOpacity.value = 0;
closeSidebar();
handleViewMoreNavigate();
- }, [backdropOpacity, closeSidebar, handleViewMoreNavigate, translateX, windowWidth]);
+ }, [closeSidebar, handleViewMoreNavigate]);
const handleViewSchedules = useCallback(() => {
- translateX.value = -windowWidth;
- backdropOpacity.value = 0;
closeSidebar();
handleViewSchedulesNavigate();
- }, [backdropOpacity, closeSidebar, handleViewSchedulesNavigate, translateX, windowWidth]);
+ }, [closeSidebar, handleViewSchedulesNavigate]);
const handleWorkspacePress = useCallback(() => {
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(
- () => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }),
- [windowWidth, insetsTop, insetsBottom],
- );
-
- const sidebarAnimatedStyle = useAnimatedStyle(() => ({
- transform: [{ translateX: translateX.value }],
- }));
-
- 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],
+ () => ({
+ paddingTop: insetsTop,
+ paddingBottom: insetsBottom,
+ backgroundColor: theme.colors.surfaceSidebar,
+ }),
+ [insetsTop, insetsBottom, theme.colors.surfaceSidebar],
);
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- {({ hovered, pressed }) => (
-
- )}
-
-
- {isInitialLoad ? (
-
- ) : (
-
- )}
-
-
+
+
+
+
+
+
+
+
+ {({ hovered, pressed }) => (
+
-
-
-
-
+ )}
+
+
+ {isInitialLoad ? (
+
+ ) : (
+
+ )}
+
+
+
+
);
}
@@ -1059,17 +900,6 @@ function WorkspacesSectionHeader() {
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
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: {
position: "relative" as const,
},
diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx
index 42a13710a..05f398d3c 100644
--- a/packages/app/src/components/terminal-pane.tsx
+++ b/packages/app/src/components/terminal-pane.tsx
@@ -180,7 +180,7 @@ export function TerminalPane({
return trimmed.length > 0 ? trimmed : undefined;
}, [settings.monoFontFamily]);
const isMobile = useIsCompactFormFactor();
- const mobileView = usePanelStore((state) => state.mobileView);
+ const mobileView = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile && mobileView === "agent";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx
deleted file mode 100644
index 987dfb6ac..000000000
--- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx
+++ /dev/null
@@ -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;
- backdropOpacity: SharedValue;
- windowWidth: number;
- animateToOpen: () => void;
- animateToClose: () => void;
- overlayVisible: boolean;
- setOverlayPeek: (peek: boolean) => void;
- isGesturing: SharedValue;
- gestureAnimatingRef: React.MutableRefObject;
- openGestureRef: React.MutableRefObject;
- closeGestureRef: React.MutableRefObject;
-}
-
-const ExplorerSidebarAnimationContext = createContext(
- 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(undefined);
- const closeGestureRef = useRef(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(
- () => ({
- translateX,
- backdropOpacity,
- windowWidth,
- animateToOpen,
- animateToClose,
- overlayVisible,
- setOverlayPeek,
- isGesturing,
- gestureAnimatingRef,
- openGestureRef,
- closeGestureRef,
- }),
- [
- translateX,
- backdropOpacity,
- windowWidth,
- animateToOpen,
- animateToClose,
- overlayVisible,
- isGesturing,
- ],
- );
-
- return (
-
- {children}
-
- );
-}
-
-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);
-}
diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx
deleted file mode 100644
index dae026bdc..000000000
--- a/packages/app/src/contexts/sidebar-animation-context.tsx
+++ /dev/null
@@ -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;
- backdropOpacity: SharedValue;
- 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;
- mobileVisualPanel: SharedValue;
- mobilePanelState: SharedValue;
- gestureAnimatingRef: React.MutableRefObject;
- openGestureRef: React.MutableRefObject;
- closeGestureRef: React.MutableRefObject;
-}
-
-const SidebarAnimationContext = createContext(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(undefined);
- const closeGestureRef = useRef(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(
- () => ({
- 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 (
- {children}
- );
-}
-
-export function useSidebarAnimation() {
- const context = useContext(SidebarAnimationContext);
- if (!context) {
- throw new Error("useSidebarAnimation must be used within SidebarAnimationProvider");
- }
- return context;
-}
diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts
deleted file mode 100644
index bddbe6e59..000000000
--- a/packages/app/src/hooks/use-explorer-open-gesture.ts
+++ /dev/null
@@ -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,
- ],
- );
-}
diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts
new file mode 100644
index 000000000..4daa44a5e
--- /dev/null
+++ b/packages/app/src/mobile-panels/gestures.ts
@@ -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;
+}
diff --git a/packages/app/src/mobile-panels/model.test.ts b/packages/app/src/mobile-panels/model.test.ts
new file mode 100644
index 000000000..258db2af2
--- /dev/null
+++ b/packages/app/src/mobile-panels/model.test.ts
@@ -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,
+ });
+ });
+});
diff --git a/packages/app/src/mobile-panels/model.ts b/packages/app/src/mobile-panels/model.ts
new file mode 100644
index 000000000..a3c4f246f
--- /dev/null
+++ b/packages/app/src/mobile-panels/model.ts
@@ -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,
+ };
+}
diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx
new file mode 100644
index 000000000..2a6cdf950
--- /dev/null
+++ b/packages/app/src/mobile-panels/presentation.tsx
@@ -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;
+
+interface MobilePanelOverlayProps {
+ children: ReactNode;
+ closeGesture: GestureType;
+ panel: OverlayPanel;
+ panelStyle?: ComponentProps["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 (
+
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+// 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,
+ },
+});
diff --git a/packages/app/src/mobile-panels/provider.tsx b/packages/app/src/mobile-panels/provider.tsx
new file mode 100644
index 000000000..77d134b9d
--- /dev/null
+++ b/packages/app/src/mobile-panels/provider.tsx
@@ -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;
+ leftOpenGestureRef: RefObject;
+ motionState: SharedValue;
+ position: SharedValue;
+ rightCloseGestureRef: RefObject;
+ rightOpenGestureRef: RefObject;
+ 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(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(undefined);
+ const leftCloseGestureRef = useRef(undefined);
+ const rightOpenGestureRef = useRef(undefined);
+ const rightCloseGestureRef = useRef(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(
+ () => ({
+ beginGesture,
+ finishGesture,
+ leftCloseGestureRef,
+ leftOpenGestureRef,
+ motionState,
+ position,
+ rightCloseGestureRef,
+ rightOpenGestureRef,
+ updateGesture,
+ windowWidth,
+ }),
+ [beginGesture, finishGesture, motionState, position, updateGesture, windowWidth],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/** 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;
+}
diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts
index 2fe2b9a28..655cb8783 100644
--- a/packages/app/src/stores/panel-store/index.ts
+++ b/packages/app/src/stores/panel-store/index.ts
@@ -26,10 +26,12 @@ import {
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
+ setMobilePanelTarget,
selectPanelVisibility,
type DesktopSidebarState,
type ExplorerPanelIntent,
type MobilePanelView,
+ type MobilePanelSelection,
type PanelLayoutInput,
type PanelVisibilityState,
type SortOption,
@@ -41,6 +43,7 @@ export type {
DesktopSidebarState,
ExplorerPanelIntent,
MobilePanelView,
+ MobilePanelSelection,
PanelLayoutInput,
PanelVisibilityState,
SortOption,
@@ -61,8 +64,8 @@ export {
};
export interface PanelState {
- // Mobile: which panel is currently shown
- mobileView: MobilePanelView;
+ // Mobile: React's durable target plus the generation that owns it.
+ mobilePanel: MobilePanelSelection;
// Desktop: independent sidebar toggles
desktop: DesktopSidebarState;
@@ -114,11 +117,19 @@ export interface PanelState {
const DEFAULT_DESKTOP_OPEN = isWeb;
+function setMobilePanelTargetPatch(
+ state: PanelState,
+ target: MobilePanelView,
+): PanelState | Pick {
+ const mobilePanel = setMobilePanelTarget(state.mobilePanel, target);
+ return mobilePanel === state.mobilePanel ? state : { mobilePanel };
+}
+
export const usePanelStore = create()(
persist(
(set) => ({
// Mobile always starts at agent view
- mobileView: "agent",
+ mobilePanel: { target: "agent", revision: 0 },
// Desktop defaults based on platform
desktop: {
@@ -144,26 +155,17 @@ export const usePanelStore = create()(
desktop: { ...state.desktop, focusModeEnabled: !state.desktop.focusModeEnabled },
})),
- showMobileAgent: () =>
- set((state) => {
- if (state.mobileView === "agent") {
- return state;
- }
- return { mobileView: "agent" as const };
- }),
+ showMobileAgent: () => set((state) => setMobilePanelTargetPatch(state, "agent")),
- showMobileAgentList: () =>
- set((state) => {
- if (state.mobileView === "agent-list") {
- return state;
- }
- return { mobileView: "agent-list" as const };
- }),
+ showMobileAgentList: () => set((state) => setMobilePanelTargetPatch(state, "agent-list")),
toggleMobileAgentList: () =>
- set((state) => ({
- mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list",
- })),
+ set((state) =>
+ setMobilePanelTargetPatch(
+ state,
+ state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
+ ),
+ ),
openDesktopAgentList: () =>
set((state) => {
@@ -197,9 +199,7 @@ export const usePanelStore = create()(
openAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
- return state.mobileView === "agent-list"
- ? state
- : { mobileView: "agent-list" as const };
+ return setMobilePanelTargetPatch(state, "agent-list");
}
return state.desktop.agentListOpen
? state
@@ -209,7 +209,7 @@ export const usePanelStore = create()(
closeAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
- return state.mobileView === "agent" ? state : { mobileView: "agent" as const };
+ return setMobilePanelTargetPatch(state, "agent");
}
return state.desktop.agentListOpen
? { desktop: { ...state.desktop, agentListOpen: false } }
@@ -219,7 +219,10 @@ export const usePanelStore = create()(
toggleAgentListForLayout: ({ isCompact }) =>
set((state) => {
if (isCompact) {
- return { mobileView: state.mobileView === "agent-list" ? "agent" : "agent-list" };
+ return setMobilePanelTargetPatch(
+ state,
+ state.mobilePanel.target === "agent-list" ? "agent" : "agent-list",
+ );
}
return {
desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen },
@@ -295,7 +298,6 @@ export const usePanelStore = create()(
migrate: (persistedState, version) =>
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
partialize: (state) => ({
- mobileView: state.mobileView,
desktop: state.desktop,
explorerTab: state.explorerTab,
explorerTabByCheckout: state.explorerTabByCheckout,
@@ -315,7 +317,7 @@ export const usePanelStore = create()(
/**
* 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).
*
* @param isMobile - Whether the current breakpoint is mobile
diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts
index 2e7cbdeb6..729b89252 100644
--- a/packages/app/src/stores/panel-store/state.test.ts
+++ b/packages/app/src/stores/panel-store/state.test.ts
@@ -10,13 +10,14 @@ import {
migratePanelState,
selectIsAgentListOpen,
selectIsFileExplorerOpen,
+ setMobilePanelTarget,
selectPanelVisibility,
type PanelCoreState,
} from "./state";
function makePanelState(overrides: Partial = {}): PanelCoreState {
return {
- mobileView: "agent",
+ mobilePanel: { target: "agent", revision: 0 },
desktop: {
agentListOpen: false,
fileExplorerOpen: false,
@@ -117,12 +118,33 @@ describe("panel-store migration", () => {
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", () => {
- 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({
- mobileView: "file-explorer",
+ mobilePanel: { target: "file-explorer", revision: 1 },
desktop: { agentListOpen: true, fileExplorerOpen: false, focusModeEnabled: false },
});
@@ -136,7 +158,7 @@ describe("panel-store visibility selectors", () => {
it("uses desktop flags for expanded layout visibility", () => {
const state = makePanelState({
- mobileView: "file-explorer",
+ mobilePanel: { target: "file-explorer", revision: 1 },
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 });
- expect(patch.mobileView).toBe("file-explorer");
+ expect(patch.mobilePanel).toEqual({ target: "file-explorer", revision: 1 });
expect(patch.desktop).toBeUndefined();
expect(patch.explorerTab).toBe("files");
});
@@ -175,7 +197,7 @@ describe("panel-store checkout-intent file explorer actions", () => {
const patch = buildOpenFileExplorerPatch(state, { isCompact: false, checkout });
- expect(patch.mobileView).toBeUndefined();
+ expect(patch.mobilePanel).toBeUndefined();
expect(patch.desktop?.fileExplorerOpen).toBe(true);
expect(patch.explorerTab).toBe("files");
});
diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts
index d43ad26ae..a7e13288b 100644
--- a/packages/app/src/stores/panel-store/state.ts
+++ b/packages/app/src/stores/panel-store/state.ts
@@ -8,6 +8,11 @@ import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
+export interface MobilePanelSelection {
+ target: MobilePanelView;
+ revision: number;
+}
+
export interface DesktopSidebarState {
agentListOpen: boolean;
fileExplorerOpen: boolean;
@@ -43,7 +48,7 @@ export interface ExplorerPanelIntent extends PanelLayoutInput {
}
export interface PanelCoreState {
- mobileView: MobilePanelView;
+ mobilePanel: MobilePanelSelection;
desktop: DesktopSidebarState;
explorerTab: ExplorerTab;
explorerTabByCheckout: Record;
@@ -74,8 +79,8 @@ export function selectPanelVisibility(
): PanelVisibilityState {
if (input.isCompact) {
return {
- isAgentListOpen: state.mobileView === "agent-list",
- isFileExplorerOpen: state.mobileView === "file-explorer",
+ isAgentListOpen: state.mobilePanel.target === "agent-list",
+ isFileExplorerOpen: state.mobilePanel.target === "file-explorer",
};
}
return {
@@ -92,6 +97,16 @@ export function selectIsFileExplorerOpen(state: PanelCoreState, input: PanelLayo
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(
state: PanelCoreState,
checkout: ExplorerCheckoutContext,
@@ -105,7 +120,7 @@ function resolveExplorerTabFromCheckout(
}
export interface OpenFileExplorerPatch {
- mobileView?: MobilePanelView;
+ mobilePanel?: MobilePanelSelection;
desktop?: DesktopSidebarState;
explorerTab: ExplorerTab;
}
@@ -117,7 +132,7 @@ export function buildOpenFileExplorerPatch(
const resolvedTab = resolveExplorerTabFromCheckout(state, input.checkout);
if (input.isCompact) {
return {
- mobileView: "file-explorer",
+ mobilePanel: setMobilePanelTarget(state.mobilePanel, "file-explorer"),
explorerTab: resolvedTab,
};
}
@@ -129,7 +144,7 @@ export function buildOpenFileExplorerPatch(
export type ToggleFileExplorerPatch =
| OpenFileExplorerPatch
- | { mobileView: MobilePanelView }
+ | { mobilePanel: MobilePanelSelection }
| { desktop: DesktopSidebarState };
export function buildToggleFileExplorerPatch(
@@ -141,7 +156,7 @@ export function buildToggleFileExplorerPatch(
return buildOpenFileExplorerPatch(state, input);
}
if (input.isCompact) {
- return { mobileView: "agent" };
+ return { mobilePanel: setMobilePanelTarget(state.mobilePanel, "agent") };
}
return { desktop: { ...state.desktop, fileExplorerOpen: false } };
}
@@ -255,6 +270,12 @@ export function migratePanelState(
if (typeof state.explorerShowHiddenFiles !== "boolean") {
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;
}
diff --git a/packages/app/src/utils/sidebar-animation-state.test.ts b/packages/app/src/utils/sidebar-animation-state.test.ts
deleted file mode 100644
index 5284333d0..000000000
--- a/packages/app/src/utils/sidebar-animation-state.test.ts
+++ /dev/null
@@ -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);
- });
-});
diff --git a/packages/app/src/utils/sidebar-animation-state.ts b/packages/app/src/utils/sidebar-animation-state.ts
deleted file mode 100644
index f62e23d00..000000000
--- a/packages/app/src/utils/sidebar-animation-state.ts
+++ /dev/null
@@ -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;
-}