From c54be00e9921e68f9fc4b528aff3105795e8cb3c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 1 Sep 2026 19:50:23 +0200 Subject: [PATCH] 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. --- docs/mobile-panels.md | 14 ++-- packages/app/src/app/_layout.tsx | 11 ++-- .../compact-explorer-sidebar-host.tsx | 17 ++--- .../components/compact-explorer-sidebar.tsx | 11 ++-- packages/app/src/components/left-sidebar.tsx | 7 +- .../src/components/sidebar-workspace-list.tsx | 32 ++++----- .../sidebar/sidebar-status-list.tsx | 6 +- packages/app/src/mobile-panels/gestures.ts | 5 +- packages/app/src/mobile-panels/model.test.ts | 66 +++++++++++++++++++ packages/app/src/mobile-panels/model.ts | 7 ++ .../app/src/mobile-panels/presentation.tsx | 27 ++++---- packages/app/src/mobile-panels/provider.tsx | 57 +++++----------- 12 files changed, 157 insertions(+), 103 deletions(-) diff --git a/docs/mobile-panels.md b/docs/mobile-panels.md index 100aacf9dc..be3e2084b5 100644 --- a/docs/mobile-panels.md +++ b/docs/mobile-panels.md @@ -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. diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 46e6f05325..c57dba4429 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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 ( {mounted ? : null} diff --git a/packages/app/src/components/compact-explorer-sidebar-host.tsx b/packages/app/src/components/compact-explorer-sidebar-host.tsx index c2be2d5a89..3a05f1c265 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host.tsx +++ b/packages/app/src/components/compact-explorer-sidebar-host.tsx @@ -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 { diff --git a/packages/app/src/components/compact-explorer-sidebar.tsx b/packages/app/src/components/compact-explorer-sidebar.tsx index ea186995bf..fc4d00cf2f 100644 --- a/packages/app/src/components/compact-explorer-sidebar.tsx +++ b/packages/app/src/components/compact-explorer-sidebar.tsx @@ -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 ( - + diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 23e3e2ca82..d4e3375142 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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 { closeSidebar(); @@ -737,7 +738,7 @@ function MobileSidebar({ onWorkspacePress={handleWorkspacePress} onAddProject={handleOpenProject} parentGestureRef={closeGestureRef} - dragGestureHostPresented={dragGestureHostPresented} + dragGestureHostActive={active} listHeaderComponent={workspacesSectionHeaderElement} /> )} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 77f813e602..65d3cab964 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -234,7 +234,7 @@ interface SidebarWorkspaceListProps { listHeaderComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; - 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; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostBadgeByServerId: ReadonlyMap; @@ -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} /> ) : ( ; - 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 ? ( diff --git a/packages/app/src/components/sidebar/sidebar-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index 84b5442152..def203c9cd 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -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; - 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 ? ( { - 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) => { diff --git a/packages/app/src/mobile-panels/model.test.ts b/packages/app/src/mobile-panels/model.test.ts index 258db2af22..fbcaf397f1 100644 --- a/packages/app/src/mobile-panels/model.test.ts +++ b/packages/app/src/mobile-panels/model.test.ts @@ -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(); diff --git a/packages/app/src/mobile-panels/model.ts b/packages/app/src/mobile-panels/model.ts index a3c4f246ff..e73d46486d 100644 --- a/packages/app/src/mobile-panels/model.ts +++ b/packages/app/src/mobile-panels/model.ts @@ -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)); diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index bc917419cd..b1d8a0036e 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -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; @@ -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. */} - + @@ -93,7 +98,7 @@ export function MobilePanelOverlay({ {children} - + ); diff --git a/packages/app/src/mobile-panels/provider.tsx b/packages/app/src/mobile-panels/provider.tsx index 93e92ab39c..6ba9556850 100644 --- a/packages/app/src/mobile-panels/provider.tsx +++ b/packages/app/src/mobile-panels/provider.tsx @@ -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(null); -const MobilePanelPresentationContext = createContext(0); +const MobilePanelActiveContext = createContext("agent"); export function MobilePanelsProvider({ children }: { children: ReactNode }) { const { width: windowWidth } = useWindowDimensions(); @@ -92,7 +79,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { const leftCloseGestureRef = useRef(undefined); const rightOpenGestureRef = useRef(undefined); const rightCloseGestureRef = useRef(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 ( - + {children} - + ); } @@ -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 {