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 {