Compare commits

...
Author SHA1 Message Date
Mohamed Boudra c54be00e99 fix(app): activate mobile panels after motion settles
Gesture previews were publishing React presentation state and activating retained content before the native motion finished. Keep transient motion on the UI thread and publish the active panel only from the accepted animation completion.
2026-09-01 19:50:23 +02:00
12 changed files with 157 additions and 103 deletions
+8 -6
View File
@@ -30,8 +30,10 @@ The UI worklet owns transient motion:
- 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`.
React publishes the active panel only after its motion reaches the final anchor. Retained content
never observes gesture previews, progress, or an unsettled target. The gesture hosts stay mounted;
worklets reveal their retained overlays through native styles while React owns pointer events and
accessibility.
## Why one position
@@ -85,8 +87,8 @@ definition, no longer eligible to begin.
- 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.
- Gesture start and progress must not update React state. The retained overlay is already mounted and
offscreen; only the shared position and its derived native styles move during a drag.
- Hidden tabs and workspaces use `RetainedPanel`. It owns a non-collapsible native root, visibility,
pointer events, and the active signal consumed by `useRetainedPanelActive`.
- Panels whose gesture wrapper already owns visibility use `RetainedPanelActivity` to provide the
@@ -98,8 +100,8 @@ definition, no longer eligible to begin.
- Retention order and render order are separate concerns. LRU metadata may change on every switch;
keyed retained roots must keep a stable sibling order. Moving large retained roots triggered Fabric
Differ failures (`addViewAt` / `removeViewAt` view reuse) on Android.
- The newly active panel must be included in the same render that changes selection. Adding it from an
effect creates a committed frame where every retained panel is hidden, which is a real blank screen.
- `useIsMobilePanelActive` follows the settled target, not the requested target. Activation work may
begin only after the finishing animation completes; cancellation publishes nothing.
- Do not suspend retained native subtrees with `Suspense`/`react-freeze`. Suspension changes native
ownership and can detach descendants. Keep the tree mounted, stabilize its subscriptions/selectors,
and use the retained-panel active signal to stop timers, polling, and other genuine background work.
+5 -6
View File
@@ -95,7 +95,7 @@ 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 { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
import { I18nProvider } from "@/i18n/provider";
import {
KeyboardActionDispatcherProvider,
@@ -114,7 +114,7 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
@@ -627,10 +627,9 @@ function SidebarChrome({
keyboardShortcutsEnabled: boolean;
}) {
const isCompactLayout = useIsCompactFormFactor();
const isOpen = usePanelStore((state) =>
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
);
const active = visible && isOpen;
const isMobileActive = useIsMobilePanelActive("agent-list");
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
return (
<SidebarModelProvider active={active}>
{mounted ? <LeftSidebar active={active} /> : null}
@@ -8,8 +8,9 @@ import {
NativeExplorerSidebarDock,
} from "@/components/compact-explorer-sidebar";
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { usePanelStore } from "@/stores/panel-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
@@ -51,7 +52,7 @@ function useActiveCompactExplorerSidebarModel(
): CompactExplorerSidebarHostModel | null {
const selection = useActiveWorkspaceSelection();
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
const isExplorerActive = useIsMobilePanelActive("file-explorer");
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const client = useHostRuntimeClient(selection?.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
@@ -67,32 +68,32 @@ function useActiveCompactExplorerSidebarModel(
const resolvedModel = useMemo(
() =>
resolveCompactExplorerSidebarHostModel({
previous: isExplorerOpen ? retainedModelRef.current : null,
previous: isExplorerActive ? retainedModelRef.current : null,
selection,
workspace,
isGit: checkoutQuery.data?.isGit ?? false,
}),
[checkoutQuery.data?.isGit, isExplorerOpen, selection, workspace],
[checkoutQuery.data?.isGit, isExplorerActive, selection, workspace],
);
useEffect(() => {
if (!selection) {
retainedModelRef.current = null;
if (enabled && isExplorerOpen) {
if (enabled && isExplorerActive) {
showMobileAgent();
}
return;
}
if (!isExplorerOpen) {
if (!isExplorerActive) {
retainedModelRef.current = null;
return;
}
if (resolvedModel) {
retainedModelRef.current = resolvedModel;
}
}, [enabled, isExplorerOpen, resolvedModel, selection, showMobileAgent]);
}, [enabled, isExplorerActive, resolvedModel, selection, showMobileAgent]);
return selection ? (resolvedModel ?? (isExplorerOpen ? retainedModelRef.current : null)) : null;
return selection ? (resolvedModel ?? (isExplorerActive ? retainedModelRef.current : null)) : null;
}
interface CompactExplorerSidebarHostProps {
@@ -14,6 +14,7 @@ import {
type ExplorerTab,
} from "@/stores/panel-store";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { useIsMobilePanelActive } from "@/mobile-panels/provider";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import {
HEADER_INNER_HEIGHT,
@@ -81,7 +82,7 @@ export function CompactExplorerSidebar({
}: ExplorerSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
const isActive = useIsMobilePanelActive("file-explorer");
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
serverId,
@@ -99,11 +100,11 @@ export function CompactExplorerSidebar({
(reason: string) => {
logExplorerSidebar("handleClose", {
reason,
isOpen,
isOpen: isActive,
});
showMobileAgent();
},
[isOpen, showMobileAgent],
[isActive, showMobileAgent],
);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
@@ -127,7 +128,7 @@ export function CompactExplorerSidebar({
);
return (
<RetainedPanelActivity active={isOpen}>
<RetainedPanelActivity active={isActive}>
<MobilePanelOverlay
panel="file-explorer"
closeGesture={closeGesture}
@@ -141,7 +142,7 @@ export function CompactExplorerSidebar({
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
isGit={isGit}
isOpen={isOpen}
isOpen={isActive}
onOpenFile={onOpenFile}
/>
</MobilePanelOverlay>
+4 -3
View File
@@ -62,7 +62,6 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { useIsMobilePanelPresented } from "@/mobile-panels/provider";
import {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
@@ -121,6 +120,7 @@ interface SidebarLabels {
}
interface MobileSidebarProps extends SidebarSharedProps {
active: boolean;
insetsTop: number;
insetsBottom: number;
closeSidebar: () => void;
@@ -273,6 +273,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
<RetainedPanelActivity active={active}>
<MobileSidebar
{...sharedProps}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
@@ -604,6 +605,7 @@ function SidebarFooter({
}
function MobileSidebar({
active,
theme,
workspaceGroups,
projectIconTargets,
@@ -638,7 +640,6 @@ function MobileSidebar({
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const dragGestureHostPresented = useIsMobilePanelPresented("agent-list");
const handleViewMore = useCallback(() => {
closeSidebar();
@@ -737,7 +738,7 @@ function MobileSidebar({
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
parentGestureRef={closeGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
@@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps {
listHeaderComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
dragGestureHostActive?: boolean;
}
interface ProjectHeaderRowProps {
@@ -1540,7 +1540,7 @@ function ProjectBlock({
isDragging,
dragHandleProps,
useNestable,
dragGestureHostPresented,
dragGestureHostActive,
creatingWorkspaceIds,
activeWorkspaceSelection,
hostBadgeByServerId,
@@ -1565,7 +1565,7 @@ function ProjectBlock({
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
useNestable: boolean;
dragGestureHostPresented?: boolean;
dragGestureHostActive?: boolean;
creatingWorkspaceIds: ReadonlySet<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
@@ -1745,7 +1745,7 @@ function ProjectBlock({
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
{canToggleWorkspaces ? (
@@ -1829,7 +1829,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previous.useNestable === next.useNestable &&
previous.dragGestureHostPresented === next.dragGestureHostPresented &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
areProjectBlockSelectionsEqual(previous, next)
);
@@ -1882,7 +1882,7 @@ export function SidebarWorkspaceList({
listFooterComponent,
listHeaderComponent,
parentGestureRef,
dragGestureHostPresented,
dragGestureHostActive,
}: SidebarWorkspaceListProps) {
const pathname = usePathname();
const hosts = useHosts();
@@ -1959,7 +1959,7 @@ export function SidebarWorkspaceList({
listHeaderComponent={listHeaderComponent}
sidebarFilterEmpty={sidebarFilterEmpty}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
dragGestureHostActive={dragGestureHostActive}
/>
) : (
<ProjectModeList
@@ -1977,7 +1977,7 @@ export function SidebarWorkspaceList({
sidebarFilterEmpty={sidebarFilterEmpty}
hasActiveProjectFilter={hasActiveProjectFilter}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
dragGestureHostActive={dragGestureHostActive}
pathname={pathname}
hostBadgeByServerId={hostBadgeByServerId}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
@@ -2010,7 +2010,7 @@ function SidebarGroupedModeList({
listHeaderComponent,
sidebarFilterEmpty,
parentGestureRef,
dragGestureHostPresented,
dragGestureHostActive,
}: {
workspaceGroups: SidebarWorkspaceGroup[];
pinnedGroups: PinnedSidebarGroups;
@@ -2025,7 +2025,7 @@ function SidebarGroupedModeList({
listHeaderComponent?: ReactElement | null;
sidebarFilterEmpty: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
dragGestureHostActive?: boolean;
}) {
const showShortcutBadges = useShowShortcutBadges();
const pinnedWorkspaces = useMemo(
@@ -2052,7 +2052,7 @@ function SidebarGroupedModeList({
listHeaderComponent={listHeaderComponent}
sidebarFilterEmpty={sidebarFilterEmpty}
parentGestureRef={parentGestureRef}
dragGestureHostPresented={dragGestureHostPresented}
dragGestureHostActive={dragGestureHostActive}
/>
);
}
@@ -2072,7 +2072,7 @@ function ProjectModeList({
sidebarFilterEmpty,
hasActiveProjectFilter,
parentGestureRef,
dragGestureHostPresented,
dragGestureHostActive,
pathname,
hostBadgeByServerId,
supportsMultiplicityByServerId,
@@ -2289,7 +2289,7 @@ function ProjectModeList({
isDragging={dragState.isDragging}
dragHandleProps={dragState.dragHandleProps}
useNestable={platformIsNative}
dragGestureHostPresented={dragGestureHostPresented}
dragGestureHostActive={dragGestureHostActive}
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
hostBadgeByServerId={hostBadgeByServerId}
@@ -2311,7 +2311,7 @@ function ProjectModeList({
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
dragGestureHostPresented,
dragGestureHostActive,
projectIconByProjectViewKey,
selectionEnabled,
shortcutIndexByWorkspaceKey,
@@ -2388,7 +2388,7 @@ function ProjectModeList({
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.projectListContainer}
/>
);
@@ -2411,7 +2411,7 @@ function ProjectModeList({
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
{canTogglePinnedChats ? (
@@ -126,7 +126,7 @@ interface StatusWorkspaceListProps {
/** Swaps the group list for the label filter's empty state. Never the header above it. */
sidebarFilterEmpty?: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostPresented?: boolean;
dragGestureHostActive?: boolean;
}
export function SidebarStatusWorkspaceList({
@@ -143,7 +143,7 @@ export function SidebarStatusWorkspaceList({
listHeaderComponent,
sidebarFilterEmpty = false,
parentGestureRef,
dragGestureHostPresented,
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
@@ -214,7 +214,7 @@ export function SidebarStatusWorkspaceList({
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostPresented}
gestureHostPresented={dragGestureHostActive}
/>
{canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow
+1 -4
View File
@@ -95,7 +95,7 @@ export function useOpenAgentListGesture(enabled: boolean) {
}
})
.onStart(() => {
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" });
startedRevision.value = beginGesture({ origin: "agent" });
})
.onUpdate((event) => {
updateGesture(startedRevision.value, -event.translationX / windowWidth);
@@ -188,7 +188,6 @@ export function useCloseAgentListGesture() {
.onStart(() => {
startedRevision.value = beginGesture({
origin: "agent-list",
preview: "agent-list",
});
})
.onUpdate((event) => {
@@ -290,7 +289,6 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer
.onStart(() => {
startedRevision.value = beginGesture({
origin: "agent",
preview: "file-explorer",
});
})
.onUpdate((event) => {
@@ -386,7 +384,6 @@ export function useCloseFileExplorerGesture() {
.onStart(() => {
startedRevision.value = beginGesture({
origin: "file-explorer",
preview: "file-explorer",
});
})
.onUpdate((event) => {
@@ -4,6 +4,7 @@ import {
canBeginMobilePanelGesture,
createMobilePanelMotionState,
getMobilePanelFrame,
isMobilePanelActive,
isMobilePanelGestureCurrent,
transitionMobilePanel,
type MobilePanelCommit,
@@ -77,6 +78,71 @@ class MobilePanelsScenario {
}
describe("mobile panel ownership", () => {
it("publishes activity only after motion settles", () => {
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
const dragging = transitionMobilePanel(initial, {
type: "gesture.begin",
origin: "agent",
}).state;
const released = transitionMobilePanel(dragging, {
type: "gesture.finish",
startedRevision: 0,
success: true,
target: "agent-list",
}).state;
const commanded = transitionMobilePanel(released, {
type: "command",
selection: { target: "agent-list", revision: 1 },
}).state;
expect(isMobilePanelActive(dragging, "agent-list")).toBe(false);
expect(isMobilePanelActive(released, "agent-list")).toBe(false);
expect(isMobilePanelActive(commanded, "agent-list")).toBe(false);
const settled = transitionMobilePanel(commanded, {
type: "animation.finished",
revision: 1,
target: "agent-list",
}).state;
expect(isMobilePanelActive(settled, "agent-list")).toBe(true);
});
it("does not change activity for a cancelled preview", () => {
const initial = createMobilePanelMotionState({ target: "agent", revision: 0 });
const dragging = transitionMobilePanel(initial, {
type: "gesture.begin",
origin: "agent",
}).state;
const cancelled = transitionMobilePanel(dragging, {
type: "gesture.finish",
startedRevision: 0,
success: false,
target: "agent-list",
}).state;
expect(isMobilePanelActive(dragging, "agent")).toBe(true);
expect(isMobilePanelActive(cancelled, "agent")).toBe(true);
expect(isMobilePanelActive(cancelled, "agent-list")).toBe(false);
});
it("keeps the visible panel active until its closing motion settles", () => {
const initial = createMobilePanelMotionState({ target: "agent-list", revision: 0 });
const closing = transitionMobilePanel(initial, {
type: "command",
selection: { target: "agent", revision: 1 },
}).state;
expect(isMobilePanelActive(closing, "agent-list")).toBe(true);
const settled = transitionMobilePanel(closing, {
type: "animation.finished",
revision: 1,
target: "agent",
}).state;
expect(isMobilePanelActive(settled, "agent-list")).toBe(false);
expect(isMobilePanelActive(settled, "agent")).toBe(true);
});
it("follows programmatic commands through left, center, and right", () => {
const panels = new MobilePanelsScenario();
+7
View File
@@ -143,6 +143,13 @@ export function isMobilePanelGestureCurrent(
return state.revision === startedRevision && state.gesture?.startedRevision === startedRevision;
}
export function isMobilePanelActive(
state: MobilePanelMotionState,
panel: MobilePanelView,
): boolean {
return state.settledTarget === panel;
}
export function getMobilePanelFrame(position: number, width: number) {
"worklet";
const clampedPosition = Math.max(-1, Math.min(1, position));
+16 -11
View File
@@ -6,7 +6,7 @@ import { isWeb } from "@/constants/platform";
import { WindowChromeRootRegion } from "@/utils/desktop-window";
import { usePanelStore, type MobilePanelView } from "@/stores/panel-store";
import { getMobilePanelFrame } from "./model";
import { useIsMobilePanelPresented, useMobilePanelsRuntime } from "./provider";
import { useIsMobilePanelActive, useMobilePanelsRuntime } from "./provider";
type OverlayPanel = Exclude<MobilePanelView, "agent">;
@@ -24,10 +24,8 @@ export function MobilePanelOverlay({
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 isOpen = useIsMobilePanelActive(panel);
const isLeft = panel === "agent-list";
const sidebarAnimatedStyle = useAnimatedStyle(() => {
@@ -42,10 +40,11 @@ export function MobilePanelOverlay({
return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity };
}, [isLeft, windowWidth]);
const overlayStyle = useMemo(
() => [styles.overlay, { display: isPresented ? ("flex" as const) : ("none" as const) }],
[isPresented],
);
const overlayAnimatedStyle = useAnimatedStyle(() => {
const isVisible = isLeft ? position.value < 0 : position.value > 0;
return { display: isVisible ? ("flex" as const) : ("none" as const) };
}, [isLeft]);
const positionedPanelStyle = isLeft ? styles.leftPanel : styles.rightPanel;
const backdropStyle = useMemo(
() => [styles.backdrop, backdropAnimatedStyle],
@@ -73,19 +72,25 @@ export function MobilePanelOverlay({
{/* Fabric needs an always-mounted native host to attach the close handler while the
retained panel content is hidden. nativeID keeps that host registered. */}
<View
accessibilityElementsHidden={!isOpen}
collapsable={false}
importantForAccessibility={isOpen ? "auto" : "no-hide-descendants"}
nativeID={`${panel}-gesture-host`}
pointerEvents={overlayPointerEvents}
style={styles.overlay}
>
<View style={overlayStyle} pointerEvents={overlayPointerEvents}>
<Animated.View
pointerEvents={overlayPointerEvents}
style={[styles.overlay, overlayAnimatedStyle]}
>
<Pressable
accessible={false}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
onPress={showMobileAgent}
pointerEvents={isOpen ? "auto" : "none"}
style={StyleSheet.absoluteFillObject}
testID={`${panel}-backdrop`}
testID={isOpen ? `${panel}-backdrop` : undefined}
>
<Animated.View pointerEvents="none" style={backdropStyle} />
</Pressable>
@@ -93,7 +98,7 @@ export function MobilePanelOverlay({
<Animated.View pointerEvents={isOpen ? "auto" : "none"} style={combinedPanelStyle}>
<WindowChromeRootRegion corners="both">{children}</WindowChromeRootRegion>
</Animated.View>
</View>
</Animated.View>
</View>
</GestureDetector>
);
+16 -41
View File
@@ -39,18 +39,6 @@ import {
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;
@@ -69,7 +57,6 @@ interface MobilePanelsRuntime {
interface BeginGestureInput {
origin: MobilePanelView;
preview: MobilePanelView;
}
interface FinishGestureInput {
@@ -79,7 +66,7 @@ interface FinishGestureInput {
}
const MobilePanelsContext = createContext<MobilePanelsRuntime | null>(null);
const MobilePanelPresentationContext = createContext(0);
const MobilePanelActiveContext = createContext<MobilePanelView>("agent");
export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const { width: windowWidth } = useWindowDimensions();
@@ -92,7 +79,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
const leftCloseGestureRef = useRef<GestureType | undefined>(undefined);
const rightOpenGestureRef = useRef<GestureType | undefined>(undefined);
const rightCloseGestureRef = useRef<GestureType | undefined>(undefined);
const [presentedPanels, setPresentedPanels] = useState(getPanelMask(initialSelection.target));
const [activePanel, setActivePanel] = useState(initialSelection.target);
const setOpenGestureBlocked = useCallback(
(owner: symbol, blocked: boolean) => {
@@ -106,19 +93,15 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
[openGesturesBlocked],
);
const presentPanel = useCallback((panel: MobilePanelView) => {
const mask = getPanelMask(panel);
if (mask) {
setPresentedPanels((current) => current | mask);
}
}, []);
const settlePresentation = useCallback((panel: MobilePanelView, revision: number) => {
const publishActivePanel = useCallback((panel: MobilePanelView, revision: number) => {
const selection = usePanelStore.getState().mobilePanel;
if (selection.revision !== revision || selection.target !== panel) {
return;
}
setPresentedPanels(getPanelMask(panel));
if (isNative && panel !== "agent") {
Keyboard.dismiss();
}
setActivePanel(panel);
}, []);
const animateTransition = useCallback(
@@ -146,11 +129,11 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
return;
}
motionState.value = settled.state;
scheduleOnRN(settlePresentation, target, revision);
scheduleOnRN(publishActivePanel, target, revision);
},
);
},
[motionState, position, settlePresentation],
[motionState, position, publishActivePanel],
);
const applySelection = useCallback(
@@ -176,18 +159,12 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
if (selection === previousState.mobilePanel) {
return;
}
if (selection.target !== "agent") {
presentPanel(selection.target);
if (isNative) {
Keyboard.dismiss();
}
}
scheduleOnUI(applySelection, selection);
});
}, [applySelection, presentPanel]);
}, [applySelection]);
const beginGesture = useCallback(
({ origin, preview }: BeginGestureInput): number => {
({ origin }: BeginGestureInput): number => {
"worklet";
const currentState = motionState.value;
if (!canBeginMobilePanelGesture(currentState, origin, position.value)) {
@@ -199,10 +176,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
});
motionState.value = transition.state;
cancelAnimation(position);
scheduleOnRN(presentPanel, preview);
return transition.state.gesture?.startedRevision ?? -1;
},
[motionState, position, presentPanel],
[motionState, position],
);
const updateGesture = useCallback(
@@ -266,9 +242,9 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) {
return (
<MobilePanelsContext.Provider value={value}>
<MobilePanelPresentationContext.Provider value={presentedPanels}>
<MobilePanelActiveContext.Provider value={activePanel}>
{children}
</MobilePanelPresentationContext.Provider>
</MobilePanelActiveContext.Provider>
</MobilePanelsContext.Provider>
);
}
@@ -282,9 +258,8 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime {
return context;
}
export function useIsMobilePanelPresented(panel: MobilePanelView): boolean {
const presentedPanels = useContext(MobilePanelPresentationContext);
return (presentedPanels & getPanelMask(panel)) !== 0;
export function useIsMobilePanelActive(panel: MobilePanelView): boolean {
return useContext(MobilePanelActiveContext) === panel;
}
export function useBlockMobilePanelOpenGestures(blocked: boolean): void {