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