fix(app): fix sidebar crash when switching iOS theme
Unistyles' StyleSheet.create((theme) => ...) wraps styled components in <UnistylesComponent> and patches native view properties via C++. When these dynamic styles are applied to Reanimated's Animated.View, a theme change causes both systems to fight over the same native node, crashing with "Unable to find node on an unmounted component." Fix: use plain React Native StyleSheet for static positioning on Animated.View, and pass theme-dependent values (backgroundColor) as inline styles from useUnistyles() instead of Unistyles dynamic sheets. Also: - Add gestureAnimatingRef to prevent double withTiming during gesture opens - Remove all debug instrumentation added during investigation - Add Maestro flow and bash script for automated theme-toggle verification - Add docs/MOBILE_TESTING.md covering Maestro patterns, self-verification loops, the Unistyles+Reanimated pitfall, and simulator commands
This commit is contained in:
@@ -0,0 +1,206 @@
|
|||||||
|
# Mobile Testing
|
||||||
|
|
||||||
|
## Maestro
|
||||||
|
|
||||||
|
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
|
||||||
|
|
||||||
|
Run a flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
maestro test packages/app/maestro/my-flow.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
|
||||||
|
mkdir -p /tmp/maestro-out
|
||||||
|
cd /tmp/maestro-out && maestro test "$FLOW"
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
|
||||||
|
|
||||||
|
### Element targeting
|
||||||
|
|
||||||
|
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Component
|
||||||
|
<Pressable testID="sidebar-sessions" onPress={onPress}>
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Flow
|
||||||
|
- tapOn:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional steps
|
||||||
|
|
||||||
|
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
|
||||||
|
|
||||||
|
### Don't use launchApp against a running dev app
|
||||||
|
|
||||||
|
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
|
||||||
|
|
||||||
|
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
|
||||||
|
|
||||||
|
### Swipe gestures
|
||||||
|
|
||||||
|
Use `start`/`end` with percentage coordinates for precise control:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Edge swipe from left to open sidebar
|
||||||
|
- swipe:
|
||||||
|
start: "5%,50%"
|
||||||
|
end: "80%,50%"
|
||||||
|
duration: 300
|
||||||
|
```
|
||||||
|
|
||||||
|
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
|
||||||
|
|
||||||
|
For async elements, use `extendedWaitUntil`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: ".*Online.*"
|
||||||
|
timeout: 90000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dev client handling
|
||||||
|
|
||||||
|
Two reusable flows handle Expo dev client screens after launch:
|
||||||
|
|
||||||
|
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||||
|
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||||
|
|
||||||
|
## Self-verification loops
|
||||||
|
|
||||||
|
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||||
|
|
||||||
|
This pattern also lets agents self-verify fixes without manual user testing.
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
|
||||||
|
1. Run baseline Maestro flow (confirm feature works)
|
||||||
|
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
|
||||||
|
3. Re-run Maestro flow (confirm feature still works)
|
||||||
|
4. Repeat N iterations to catch intermittent failures
|
||||||
|
|
||||||
|
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
|
||||||
|
|
||||||
|
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||||
|
# Args: iterations=6, wait_seconds=1 between toggle and test
|
||||||
|
```
|
||||||
|
|
||||||
|
Key elements of the script pattern:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -euo pipefail
|
||||||
|
ITERATIONS="${1:-3}"
|
||||||
|
|
||||||
|
for i in $(seq 1 "$ITERATIONS"); do
|
||||||
|
# Toggle system state
|
||||||
|
xcrun simctl ui booted appearance light
|
||||||
|
|
||||||
|
# Wait for change to propagate
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Run Maestro flow and capture result
|
||||||
|
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||||
|
echo "PASS"
|
||||||
|
else
|
||||||
|
echo "FAIL"
|
||||||
|
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unistyles + Reanimated
|
||||||
|
|
||||||
|
### The crash
|
||||||
|
|
||||||
|
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
|
||||||
|
|
||||||
|
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
|
||||||
|
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// BAD: Unistyles dynamic style on Animated.View
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
sidebar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// GOOD: static stylesheet + inline theme values
|
||||||
|
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||||
|
|
||||||
|
const staticStyles = RNStyleSheet.create({
|
||||||
|
sidebar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||||
|
|
||||||
|
## iOS Simulator
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Screenshot
|
||||||
|
xcrun simctl io booted screenshot /tmp/screenshot.png
|
||||||
|
|
||||||
|
# Dark/light mode
|
||||||
|
xcrun simctl ui booted appearance # check current
|
||||||
|
xcrun simctl ui booted appearance dark # set dark
|
||||||
|
xcrun simctl ui booted appearance light # set light
|
||||||
|
```
|
||||||
|
|
||||||
|
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Maestro takeScreenshot artifacts
|
||||||
|
*.png
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
commands:
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
|
||||||
|
# Small pause for close animation
|
||||||
|
- takeScreenshot: 00-sidebar-closed
|
||||||
|
|
||||||
|
# Open sidebar via swipe right gesture (the actual path that triggers the bug)
|
||||||
|
- swipe:
|
||||||
|
start: "5%,50%"
|
||||||
|
end: "80%,50%"
|
||||||
|
duration: 300
|
||||||
|
|
||||||
|
# Verify sidebar opened
|
||||||
|
- assertVisible:
|
||||||
|
id: "sidebar-sessions"
|
||||||
|
- takeScreenshot: 01-sidebar-opened
|
||||||
|
|
||||||
|
# Close sidebar via swipe left
|
||||||
|
- swipe:
|
||||||
|
direction: LEFT
|
||||||
|
duration: 300
|
||||||
|
- takeScreenshot: 02-sidebar-closed-again
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verification loop for sidebar theme bug.
|
||||||
|
#
|
||||||
|
# Maestro can't toggle iOS appearance, so this script bridges the gap:
|
||||||
|
# toggle appearance via xcrun simctl, then run Maestro to verify the sidebar
|
||||||
|
# still works. Runs N iterations to catch intermittent failures.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash packages/app/maestro/test-sidebar-theme.sh [iterations] [wait_seconds]
|
||||||
|
# bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||||
|
FLOW="$REPO_ROOT/packages/app/maestro/sidebar-theme-repro.yaml"
|
||||||
|
OUT_DIR="/tmp/sidebar-theme-test-$(date +%s)"
|
||||||
|
ITERATIONS="${1:-3}"
|
||||||
|
WAIT_SECS="${2:-1}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
echo "=== Sidebar Theme Bug Verification ==="
|
||||||
|
echo "Output dir: $OUT_DIR"
|
||||||
|
echo "Iterations: $ITERATIONS, wait after toggle: ${WAIT_SECS}s"
|
||||||
|
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
for i in $(seq 1 "$ITERATIONS"); do
|
||||||
|
echo ""
|
||||||
|
echo "========== Iteration $i / $ITERATIONS =========="
|
||||||
|
|
||||||
|
CURRENT=$(xcrun simctl ui booted appearance 2>&1 | tr -d '[:space:]')
|
||||||
|
echo "Current appearance: $CURRENT"
|
||||||
|
|
||||||
|
if [ "$CURRENT" = "dark" ]; then
|
||||||
|
xcrun simctl ui booted appearance light
|
||||||
|
echo "Switched to light mode"
|
||||||
|
else
|
||||||
|
xcrun simctl ui booted appearance dark
|
||||||
|
echo "Switched to dark mode"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting ${WAIT_SECS}s..."
|
||||||
|
sleep "$WAIT_SECS"
|
||||||
|
|
||||||
|
ITER_DIR="$OUT_DIR/iter-$i"
|
||||||
|
mkdir -p "$ITER_DIR"
|
||||||
|
|
||||||
|
# Run maestro from the output dir so takeScreenshot artifacts land there
|
||||||
|
if (cd "$ITER_DIR" && maestro test "$FLOW") 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||||
|
echo " -> PASS (iteration $i)"
|
||||||
|
else
|
||||||
|
echo " -> FAIL (iteration $i) — bug reproduced!"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Restore to dark mode
|
||||||
|
xcrun simctl ui booted appearance dark
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Summary: $FAILURES failures out of $ITERATIONS iterations ==="
|
||||||
|
echo "Output: $OUT_DIR"
|
||||||
@@ -420,14 +420,28 @@ function MobileGestureWrapper({
|
|||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
const {
|
||||||
useSidebarAnimation();
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
|
} = useSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||||
|
|
||||||
|
const handleGestureOpen = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
openAgentList();
|
||||||
|
}, [openAgentList, gestureAnimatingRef]);
|
||||||
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
|
.withRef(openGestureRef)
|
||||||
.enabled(openGestureEnabled)
|
.enabled(openGestureEnabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
@@ -470,7 +484,7 @@ function MobileGestureWrapper({
|
|||||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(openAgentList)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
@@ -485,8 +499,9 @@ function MobileGestureWrapper({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
openAgentList,
|
handleGestureOpen,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
openGestureRef,
|
||||||
horizontalScroll?.isAnyScrolledRight,
|
horizontalScroll?.isAnyScrolledRight,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
Platform,
|
||||||
|
useWindowDimensions,
|
||||||
|
StyleSheet as RNStyleSheet,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||||
@@ -81,6 +88,7 @@ export function ExplorerSidebar({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useExplorerSidebarAnimation();
|
} = useExplorerSidebarAnimation();
|
||||||
|
|
||||||
@@ -101,6 +109,11 @@ export function ExplorerSidebar({
|
|||||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
closeToAgent();
|
||||||
|
}, [closeToAgent, gestureAnimatingRef]);
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
|
|
||||||
const handleTabPress = useCallback(
|
const handleTabPress = useCallback(
|
||||||
@@ -175,7 +188,7 @@ export function ExplorerSidebar({
|
|||||||
});
|
});
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleClose)("swipe-close-gesture");
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
@@ -190,7 +203,7 @@ export function ExplorerSidebar({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleClose,
|
handleCloseFromGesture,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
closeTouchStartX,
|
closeTouchStartX,
|
||||||
@@ -251,18 +264,13 @@ export function ExplorerSidebar({
|
|||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
|
||||||
<Pressable
|
|
||||||
style={styles.backdropPressable}
|
|
||||||
onPress={() => handleClose("backdrop-press")}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.mobileSidebar,
|
explorerStaticStyles.mobileSidebar,
|
||||||
{ width: windowWidth, paddingTop: insets.top },
|
{ width: windowWidth, paddingTop: insets.top, backgroundColor: theme.colors.surfaceSidebar },
|
||||||
sidebarAnimatedStyle,
|
sidebarAnimatedStyle,
|
||||||
mobileKeyboardInsetStyle,
|
mobileKeyboardInsetStyle,
|
||||||
]}
|
]}
|
||||||
@@ -291,25 +299,27 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
<Animated.View style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}>
|
||||||
{/* Resize handle - absolutely positioned over left border */}
|
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||||
<GestureDetector gesture={resizeGesture}>
|
{/* Resize handle - absolutely positioned over left border */}
|
||||||
<View
|
<GestureDetector gesture={resizeGesture}>
|
||||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
<View
|
||||||
/>
|
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||||
</GestureDetector>
|
/>
|
||||||
|
</GestureDetector>
|
||||||
|
|
||||||
<SidebarContent
|
<SidebarContent
|
||||||
activeTab={explorerTab}
|
activeTab={explorerTab}
|
||||||
onTabPress={handleTabPress}
|
onTabPress={handleTabPress}
|
||||||
onClose={() => handleClose("desktop-close-button")}
|
onClose={() => handleClose("desktop-close-button")}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -400,24 +410,28 @@ function SidebarContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||||
|
// 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: {
|
backdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...RNStyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
},
|
},
|
||||||
backdropPressable: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
position: "absolute",
|
position: "absolute" as const,
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
overflow: "hidden" as const,
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebar: {
|
||||||
position: "relative",
|
position: "relative" as const,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
desktopSidebarBorder: {
|
||||||
borderLeftWidth: 1,
|
borderLeftWidth: 1,
|
||||||
borderLeftColor: theme.colors.border,
|
borderLeftColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ import {
|
|||||||
type RefObject,
|
type RefObject,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { View, Pressable, Text, Platform, useWindowDimensions } from "react-native";
|
import {
|
||||||
|
View,
|
||||||
|
Pressable,
|
||||||
|
Text,
|
||||||
|
Platform,
|
||||||
|
useWindowDimensions,
|
||||||
|
StyleSheet as RNStyleSheet,
|
||||||
|
} from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -370,14 +377,16 @@ function MobileSidebar({
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
} = useSidebarAnimation();
|
} = useSidebarAnimation();
|
||||||
const closeTouchStartX = useSharedValue(0);
|
const closeTouchStartX = useSharedValue(0);
|
||||||
const closeTouchStartY = useSharedValue(0);
|
const closeTouchStartY = useSharedValue(0);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
closeToAgent();
|
closeToAgent();
|
||||||
}, [closeToAgent]);
|
}, [closeToAgent, gestureAnimatingRef]);
|
||||||
|
|
||||||
const handleViewMore = useCallback(() => {
|
const handleViewMore = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
@@ -452,7 +461,7 @@ function MobileSidebar({
|
|||||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||||
if (shouldClose) {
|
if (shouldClose) {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleClose)();
|
runOnJS(handleCloseFromGesture)();
|
||||||
} else {
|
} else {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
}
|
}
|
||||||
@@ -471,7 +480,7 @@ function MobileSidebar({
|
|||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
handleClose,
|
handleCloseFromGesture,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -498,13 +507,11 @@ function MobileSidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
|
||||||
<Animated.View style={[styles.backdrop, backdropAnimatedStyle]}>
|
<Animated.View style={[staticStyles.backdrop, backdropAnimatedStyle]} />
|
||||||
<Pressable style={styles.backdropPressable} onPress={handleClose} />
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle]}
|
style={[staticStyles.mobileSidebar, mobileSidebarInsetStyle, sidebarAnimatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
>
|
>
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
@@ -526,7 +533,7 @@ function MobileSidebar({
|
|||||||
projects={projects}
|
projects={projects}
|
||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onWorkspacePress={closeToAgent}
|
onWorkspacePress={() => closeToAgent()}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
/>
|
/>
|
||||||
@@ -688,7 +695,8 @@ function DesktopSidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
<Animated.View style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}>
|
||||||
|
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||||
<View style={styles.sidebarDragArea}>
|
<View style={styles.sidebarDragArea}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||||
@@ -796,32 +804,37 @@ function DesktopSidebar({
|
|||||||
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
style={[styles.resizeHandle, Platform.OS === "web" && ({ cursor: "col-resize" } as any)]}
|
||||||
/>
|
/>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||||
|
// 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: {
|
backdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...RNStyleSheet.absoluteFillObject,
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
},
|
},
|
||||||
backdropPressable: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
mobileSidebar: {
|
mobileSidebar: {
|
||||||
position: "absolute",
|
position: "absolute" as const,
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
overflow: "hidden" as const,
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
|
desktopSidebar: {
|
||||||
|
position: "relative" as const,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
sidebarContent: {
|
sidebarContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
desktopSidebar: {
|
desktopSidebarBorder: {
|
||||||
position: "relative",
|
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: theme.colors.border,
|
borderRightColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
backgroundColor: theme.colors.surfaceSidebar,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface ExplorerSidebarAnimationContextValue {
|
|||||||
animateToOpen: () => void;
|
animateToOpen: () => void;
|
||||||
animateToClose: () => void;
|
animateToClose: () => void;
|
||||||
isGesturing: SharedValue<boolean>;
|
isGesturing: SharedValue<boolean>;
|
||||||
|
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||||
|
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
const translateX = useSharedValue(initialTargets.translateX);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
|
const gestureAnimatingRef = useRef(false);
|
||||||
|
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
@@ -61,6 +65,11 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
return;
|
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
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
@@ -123,6 +132,8 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ interface SidebarAnimationContextValue {
|
|||||||
animateToOpen: () => void;
|
animateToOpen: () => void;
|
||||||
animateToClose: () => void;
|
animateToClose: () => void;
|
||||||
isGesturing: SharedValue<boolean>;
|
isGesturing: SharedValue<boolean>;
|
||||||
|
gestureAnimatingRef: React.MutableRefObject<boolean>;
|
||||||
|
openGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
closeGestureRef: React.MutableRefObject<GestureType | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +48,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
const translateX = useSharedValue(initialTargets.translateX);
|
const translateX = useSharedValue(initialTargets.translateX);
|
||||||
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
const backdropOpacity = useSharedValue(initialTargets.backdropOpacity);
|
||||||
const isGesturing = useSharedValue(false);
|
const isGesturing = useSharedValue(false);
|
||||||
|
const gestureAnimatingRef = useRef(false);
|
||||||
|
const openGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
const closeGestureRef = useRef<GestureType | undefined>(undefined);
|
||||||
|
|
||||||
// Track previous isOpen to detect changes
|
// Track previous isOpen to detect changes
|
||||||
@@ -68,6 +72,14 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Don't animate if we're in the middle of a gesture - the gesture handler will handle it
|
||||||
if (isGesturing.value) {
|
if (isGesturing.value) {
|
||||||
return;
|
return;
|
||||||
@@ -123,6 +135,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
@@ -132,6 +146,8 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
import { Gesture } from "react-native-gesture-handler";
|
||||||
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
@@ -9,14 +9,28 @@ interface UseExplorerOpenGestureParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
|
export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) {
|
||||||
const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } =
|
const {
|
||||||
useExplorerSidebarAnimation();
|
translateX,
|
||||||
|
backdropOpacity,
|
||||||
|
windowWidth,
|
||||||
|
animateToOpen,
|
||||||
|
animateToClose,
|
||||||
|
isGesturing,
|
||||||
|
gestureAnimatingRef,
|
||||||
|
openGestureRef,
|
||||||
|
} = useExplorerSidebarAnimation();
|
||||||
const touchStartX = useSharedValue(0);
|
const touchStartX = useSharedValue(0);
|
||||||
const touchStartY = useSharedValue(0);
|
const touchStartY = useSharedValue(0);
|
||||||
|
|
||||||
|
const handleGestureOpen = useCallback(() => {
|
||||||
|
gestureAnimatingRef.current = true;
|
||||||
|
onOpen();
|
||||||
|
}, [onOpen, gestureAnimatingRef]);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
|
.withRef(openGestureRef)
|
||||||
.enabled(enabled)
|
.enabled(enabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
.onTouchesDown((event) => {
|
.onTouchesDown((event) => {
|
||||||
@@ -78,7 +92,7 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
const shouldOpen = shouldOpenByPosition || shouldOpenByVelocity;
|
||||||
if (shouldOpen) {
|
if (shouldOpen) {
|
||||||
animateToOpen();
|
animateToOpen();
|
||||||
runOnJS(onOpen)();
|
runOnJS(handleGestureOpen)();
|
||||||
} else {
|
} else {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
}
|
}
|
||||||
@@ -94,7 +108,8 @@ export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestu
|
|||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
isGesturing,
|
isGesturing,
|
||||||
onOpen,
|
openGestureRef,
|
||||||
|
handleGestureOpen,
|
||||||
touchStartX,
|
touchStartX,
|
||||||
touchStartY,
|
touchStartY,
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user