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; -}