diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8b6834c9714..de53a37c995 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,7 +49,7 @@ "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/metro-runtime": "~56.0.15", "@expo/ui": "~56.0.18", - "@legendapp/list": "3.3.3", + "@legendapp/list": "catalog:", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index da1be88a8bd..93bb6165524 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; -import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; @@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { presentation: "fullScreenModal", }; -const SettingsSheetStack = createNativeStackNavigator({ +const SettingsContentStack = createNativeStackNavigator({ initialRouteName: "Settings", screenOptions: { ...GLASS_HEADER_OPTIONS, @@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Usage", }, }), + }, +}); + +// The outer stack never owns visible chrome. Settings routes render inside a +// nested stack whose native header remains mounted, while Clerk owns auth chrome. +// Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. +const SettingsSheetStack = createNativeStackNavigator({ + initialRouteName: "SettingsContent", + screenOptions: { + headerShown: false, + }, + screens: { + SettingsContent: createNativeStackScreen({ + screen: SettingsContentStack, + linking: "", + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", - options: { - title: "Sign in", - }, }), SettingsWaitlist: createNativeStackScreen({ // Keep the old deep link working after the Connect GA launch. screen: SettingsAuthRouteScreen, linking: "waitlist", - options: { - title: "Sign in", - }, }), }, }); @@ -347,11 +356,9 @@ function RootStackLayout(props: { - - - {props.children} - - + + {props.children} + ); } diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 587abcc06f5..abcfc7f7b80 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -6,6 +6,7 @@ import { type ComponentProps, type ReactElement, type ReactNode, + useRef, } from "react"; import { Platform, Pressable, useColorScheme, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; @@ -21,10 +22,31 @@ export function ControlPill(props: { readonly label?: string; readonly accessibilityLabel?: string; readonly onPress?: () => void; + readonly activateOnPressIn?: boolean; readonly variant?: "circle" | "pill" | "primary" | "danger"; readonly disabled?: boolean; + readonly className?: string; }) { const variant = props.variant ?? "circle"; + const activatedOnPressInRef = useRef(false); + + const handlePressIn = () => { + activatedOnPressInRef.current = true; + props.onPress?.(); + }; + const handlePressOut = () => { + // Pressability invokes onPressOut immediately before onPress on release. + // Defer the reset so onPress can identify the same physical gesture. + setTimeout(() => { + activatedOnPressInRef.current = false; + }, 0); + }; + const handlePress = () => { + if (activatedOnPressInRef.current) { + return; + } + props.onPress?.(); + }; const iconColor = useThemeColor("--color-icon"); const iconSubtle = useThemeColor("--color-icon-subtle"); @@ -54,6 +76,7 @@ export function ControlPill(props: { : variant === "danger" ? "bg-danger" : "bg-subtle", + props.className, ); const labelClassName = cn( "text-center text-xs font-t3-bold", @@ -68,7 +91,9 @@ export function ControlPill(props: { diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx index c2381ef2580..9ad4790faab 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -5,7 +5,6 @@ import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useMemo, useState } from "react"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; import { useArchivedThreadListActions } from "../home/useThreadListActions"; import { ArchivedThreadsScreen, @@ -18,7 +17,6 @@ import { } from "./useArchivedThreadSnapshots"; export function ArchivedThreadsRouteScreen() { - const { expand } = useClerkSettingsSheetDetent(); const { savedConnectionsById } = useSavedRemoteConnections(); const [searchQuery, setSearchQuery] = useState(""); const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); @@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() { useFocusEffect( useCallback(() => { - expand(); refresh(); - }, [expand, refresh]), + }, [refresh]), ); return ( diff --git a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx deleted file mode 100644 index 8bd51b8518d..00000000000 --- a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useMemo, - useState, -} from "react"; - -interface ClerkSettingsSheetDetentValue { - collapse: () => void; - expand: () => void; - isExpanded: boolean; -} - -const ClerkSettingsSheetDetentContext = createContext(null); - -interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { - initiallyExpanded: boolean; -} - -export function ClerkSettingsSheetDetentProvider({ - children, - initiallyExpanded, -}: ClerkSettingsSheetDetentProviderProps) { - const [isExpanded, setIsExpanded] = useState(initiallyExpanded); - const collapse = useCallback(() => setIsExpanded(false), []); - const expand = useCallback(() => setIsExpanded(true), []); - const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); - - return ( - {children} - ); -} - -export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { - const value = useContext(ClerkSettingsSheetDetentContext); - if (!value) { - throw new Error( - "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", - ); - } - return value; -} diff --git a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts index f937453e525..5c75df80cd3 100644 --- a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts +++ b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts @@ -6,8 +6,8 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; -// Sign-in happens inside the Settings sheet; give its detent/session-state -// transitions a beat to settle before presenting another formSheet on top. +// Sign-in happens inside the Settings sheet; give its session-state transition +// a beat to settle before presenting another formSheet on top. const PRESENT_ONBOARDING_DELAY_MS = 600; /** diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7760920f7db..d67446a61b4 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -134,7 +134,10 @@ export function HomeRouteScreen() { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }), })} /> - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }) + } + onOpenSettings={() => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }) } - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} @@ -161,7 +172,10 @@ export function HomeRouteScreen() { catalogState={catalogState} environments={environments} onAddConnection={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} @@ -174,7 +188,12 @@ export function HomeRouteScreen() { onMovePinnedThread={movePinnedThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} + onOpenSettings={() => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }) + } onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index a93268d0da6..e00433de0ed 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent( ); const handleOpenSettings = useCallback(() => { - navigation.navigate("SettingsSheet", { screen: "Settings" }); + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }); }, [navigation]); // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = useCallback(() => { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }); }, [navigation]); const handleNewThreadInProject = useCallback( diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index e4efdf70c31..5bc10af0a40 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -1,8 +1,7 @@ import { useAuth } from "@clerk/expo"; import { AuthView, UserProfileView } from "@clerk/expo/native"; import { StackActions, useNavigation } from "@react-navigation/native"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useEffect } from "react"; +import { useCallback, useLayoutEffect } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig"; export function SettingsAuthRouteScreen() { const navigation = useNavigation(); - useEffect(() => { + useLayoutEffect(() => { if (!hasCloudPublicConfig()) { - navigation.dispatch(StackActions.replace("Settings")); + navigation.dispatch(StackActions.replace("SettingsContent")); } }, [navigation]); @@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() { function ConfiguredSettingsAuthRouteScreen() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const navigation = useNavigation(); - const handleHostBack = useCallback(() => navigation.goBack(), [navigation]); + const handleHostBack = useCallback( + () => navigation.dispatch(StackActions.popTo("SettingsContent")), + [navigation], + ); return ( - <> - - - {isLoaded ? ( - isSignedIn ? ( - - ) : ( - - ) - ) : null} - - + + {isLoaded ? ( + isSignedIn ? ( + + ) : ( + + ) + ) : null} + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 53bbe480646..aa30242ea72 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() { accessibilityLabel: "Add environment", icon: "plus", onPress: () => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }), + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }), }, ]} /> @@ -108,7 +111,10 @@ export function SettingsEnvironmentsRouteScreen() { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) } separateBackground tintColor={headerIconColor} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 90e5af199de..4fb4b1a97a5 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; -import * as Updates from "expo-updates"; import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; @@ -30,7 +29,6 @@ import { subscribeAgentAwarenessRegistrationStatus, } from "../agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; @@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, + isAppUpdateCheckAvailable, registerHiddenUpdateTap, runAppUpdateCheck, } from "../updates/app-updates"; @@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() { const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const { savedConnectionsById } = useSavedRemoteConnections(); @@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() { const openAccount = useCallback(() => { if (!isLoaded) return; - if (!isSignedIn) { - expandClerkSheet(); - navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - return; - } - expandClerkSheet(); navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - }, [expandClerkSheet, isLoaded, isSignedIn, navigation]); + }, [isLoaded, navigation]); return ( @@ -577,6 +569,7 @@ function AppSettingsSection() { const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; const variantLabel = variant === "production" ? "" : capitalize(variant); const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; + const updateCheckAvailable = isAppUpdateCheckAvailable(); const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; @@ -604,13 +597,13 @@ function AppSettingsSection() { }, []); const handleVersionPress = useCallback(() => { - if (!Updates.isEnabled || updateInFlight.current) return; + if (!updateCheckAvailable || updateInFlight.current) return; const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); hiddenUpdateTapCount.current = tap.nextCount; if (tap.shouldCheck) { void checkForUpdate(); } - }, [checkForUpdate]); + }, [checkForUpdate, updateCheckAvailable]); const statusLabel = updateState === "checking" @@ -646,7 +639,7 @@ function AppSettingsSection() { - {Updates.isEnabled ? ( + {updateCheckAvailable ? ( navigation.navigate("SettingsSheet", { - screen: target, + screen: "SettingsContent", + params: { screen: target }, }) } > diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index ffeca9671b7..424822c35eb 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { Keyboard, View } from "react-native"; -import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { + CommonActions, + type NavigationState, + type PartialState, + StackActions, + useNavigation, +} from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useConnectionController } from "../connection/useConnectionController"; @@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; +type ShowcaseResetRoute = PartialState["routes"][number]; + function sceneFromPathname(pathname: string): ShowcaseScene | null { const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { @@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) navigation.dispatch(StackActions.popToTop()); return; } - const routes: Array<{ - name: string; - params?: Record; - state?: { index: number; routes: Array<{ name: string }> }; - }> = [{ name: "Home" }]; + const routes: ShowcaseResetRoute[] = [{ name: "Home" }]; if (requestedScene === "environments") { routes.push({ name: "SettingsSheet", state: { - index: 1, - routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + index: 0, + routes: [ + { + name: "SettingsContent", + state: { + index: 1, + routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + }, + }, + ], }, }); } else { diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index bf2dfa8f4d4..1ece23ca055 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; -import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; +import { + KeyboardAvoidingView, + KeyboardStickyView, + useKeyboardState, +} from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; @@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: { // The draft is a thread that doesn't exist yet, so it mirrors the thread // page: in-screen header, empty feed canvas above, and the same floating // composer chrome as ThreadComposer (collapsed pill → expanded card). + // + // Composer positioning mirrors ThreadDetailScreen's floating overlay + // (KeyboardStickyView, absolute bottom overlay) rather than + // KeyboardAvoidingView's automaticOffset+padding: automaticOffset + // resolves the composer's on-screen frame via a native + // viewPositionInWindow measurement, which this app's Android + // edge-to-edge setup (KeyboardProvider's native content-view margin + // handling neutralizes windowSoftInputMode="adjustResize" while active) + // makes unreliable — the composer stayed under the keyboard instead of + // translating above it. KeyboardStickyView sticks directly to the + // animated keyboard height instead, sidestepping that measurement. return ( navigation.goBack()} /> - - + + ) : null} - + {settingsSheet} ); diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index c3c9b4e7ce8..ddb625f9b21 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,18 +1,64 @@ -import type { ApprovalRequestId } from "@t3tools/contracts"; -import { Pressable, View } from "react-native"; +import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; +import { useCallback, useRef } from "react"; +import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; +import Animated, { + Easing, + FadeInUp, + FadeOutDown, + LinearTransition, + useAnimatedStyle, + useSharedValue, + withTiming, + type SharedValue, +} from "react-native-reanimated"; +import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; + +import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; -import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + isPendingUserInputOptionSelected, + type PendingUserInput, + type PendingUserInputDraftAnswer, +} from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; + /** + * Constant while a request is pending (it reserves keyboard space), so the + * keyboard transition is pure translation; changes only on rare discrete + * corrections, which the layout transition smooths. + */ + readonly maxHeight: number; + readonly collapsed: boolean; + readonly onToggleCollapsed: () => void; + /** Renders a stop control on the collapsed bar, which replaces the composer. */ + readonly onStopThread?: () => void; + /** + * 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the + * collapsed bar (inside a clipping window) on the UI thread; the host + * animates it directly from the tap handler so the card and the feed + * inset glide start the same frame. + */ + readonly cardProgress?: SharedValue; + /** + * Receives how far the expanded card extends above the bar footprint + * (written from onLayout with no re-render); the host adds it to the + * thread feed's end inset so the end of the chat stays visible above the + * card. + */ + readonly cardCoverage?: SharedValue; + /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */ + readonly onInputFocusChange?: (focused: boolean) => void; readonly drafts: Record; - readonly answers: Record | null; + readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly onSelectOption: ( requestId: ApprovalRequestId, - questionId: string, + question: UserInputQuestion, label: string, ) => void; readonly onChangeCustomAnswer: ( @@ -23,74 +69,242 @@ export interface PendingUserInputCardProps { readonly onSubmit: () => Promise; } +/** + * On iOS the collapsed bar is the PERMANENT in-flow footprint — the expanded + * card is an absolutely-positioned overlay rising above it. The overlay's + * measured height (which drives the thread feed's bottom inset) therefore + * never changes on collapse/expand, so the transcript stays perfectly still + * while the card animates over it. + * + * Android cannot use the overlay: it does not hit-test touches outside a + * parent's bounds, which made everything above the bar-sized wrapper + * untouchable. There the expanded card renders in-flow instead (the wrapper + * grows with it, and the host skips the coverage inset since the measured + * overlay already includes the card). + */ +const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; + +const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); + export function PendingUserInputCard(props: PendingUserInputCardProps) { - // The surface is opaque on purpose: the card floats over the thread feed - // with no blur behind it, so a translucent background renders the questions - // on top of whatever message happens to sit underneath. - return ( - - - User input needed - - - Fill in the pending answers - - {props.pendingUserInput.questions.map((question) => { - const draft = props.drafts[question.id]; - return ( - - - {question.header} - - - {question.question} - - - {question.options.map((option) => { - const selected = - draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; - return ( - - props.onSelectOption( - props.pendingUserInput.requestId, - question.id, - option.label, - ) - } - > - { + if (!cardCoverage) { + return; + } + const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); + if (coverage === cardCoverage.value) { + return; + } + if (cardCoverage.value === 0) { + // First measurement lands while the list is doing its initial + // end-pin (thread opened onto a pending request); animating it from + // zero would move the end anchor out from under that scroll. + cardCoverage.value = coverage; + return; + } + // Animated so a coverage change at rest (discrete max-height + // corrections) glides the feed instead of stepping it; toggle timing is + // owned by the host's progress values. + cardCoverage.value = withTiming(coverage, { + duration: USER_INPUT_TOGGLE_DURATION_MS, + easing: Easing.out(Easing.cubic), + }); + }, [cardCoverage]); + const handleBarLayout = useCallback( + (event: LayoutChangeEvent) => { + barHeightRef.current = event.nativeEvent.layout.height; + notifyCoverage(); + }, + [notifyCoverage], + ); + const handleCardLayout = useCallback( + (event: LayoutChangeEvent) => { + cardHeightRef.current = event.nativeEvent.layout.height; + cardHeight.value = event.nativeEvent.layout.height; + notifyCoverage(); + }, + [cardHeight, notifyCoverage], + ); + const cardProgress = props.cardProgress; + // No opacity: fading an opaque card over the live transcript reads as a + // crossfade (card text, transcript, and bar all half-visible at once). + // Instead the card stays opaque and slides its full height down past the + // clipping window's bottom edge, so the transcript is only revealed where + // the card has physically left. + const cardAnimatedStyle = useAnimatedStyle(() => { + const progress = cardProgress === undefined ? 1 : cardProgress.value; + return { + transform: [{ translateY: (1 - progress) * cardHeight.value }], + }; + }); + + // On iOS the card stays MOUNTED while collapsed (hidden via the animated + // style): expanding animates existing views on the UI thread the same + // frame the host starts the progress timing, instead of paying a React + // mount + layout before anything moves. + const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed; + const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; + // The bar renders UNDER the card (earlier in JSX), always opaque: while + // expanded the opaque card covers it, and during the collapse slide the + // card's top edge wipes past and reveals it — no opacity handoff, so no + // crossfade frames. + const bar = showBar ? ( + + + + User input needed + + + {questionCount} question{questionCount === 1 ? "" : "s"} + + + + + {props.onStopThread ? ( + + ) : null} + + ) : null; + const card = renderCard ? ( + // The surface is opaque on purpose: the card floats over the thread + // feed with no blur behind it, so a translucent background renders + // the questions on top of whatever message happens to sit underneath. + + + + + User input needed + + + Fill in the pending answers + + + + + + + + {props.pendingUserInput.questions.map((question) => { + const draft = props.drafts[question.id]; + return ( + + + {question.header} + + + {question.question} + + + {question.options.map((option) => { + const selected = isPendingUserInputOptionSelected(draft, option.label); + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question, + option.label, + ) + } > - {option.label} - - - ); - })} + + {option.label} + + + ); + })} + + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + onFocus={() => props.onInputFocusChange?.(true)} + onBlur={() => props.onInputFocusChange?.(false)} + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + /> - - props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) - } - placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" - /> - - ); - })} + ); + })} + Submit answers + + ) : null; + return ( + + {bar} + {EXPANDED_CARD_IS_OVERLAY ? ( + // Clipping window for the collapse slide: same footprint as the + // expanded card, bottom edge on the bar's bottom edge. The sliding + // card exits through the bottom edge instead of drawing over the + // composer area, wiping the bar (and the transcript) into view. + + {card} + + ) : ( + card + )} ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c846dca287a..6ce42aeb148 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -14,6 +14,7 @@ import { serializeComposerFileLink, type ComposerTrigger, } from "@t3tools/shared/composerTrigger"; +import * as Haptics from "expo-haptics"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -51,7 +52,7 @@ import { ComposerToolbarScroller, ComposerToolbarTrigger, } from "../../components/ComposerToolbarTrigger"; -import { ControlPill } from "../../components/ControlPill"; +import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -62,9 +63,13 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; -import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; +import { + applyProviderOptionSelection, + resolveProviderOptionDescriptors, +} from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { buildThreadSettingsMenu } from "./thread-settings-menu"; import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; @@ -113,6 +118,8 @@ export interface ThreadComposerProps { readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; + /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ + readonly onEditorFocusChange?: (focused: boolean) => void; } /** @@ -307,13 +314,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } }, [inputRef]); + const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); - }, []); + onEditorFocusChange?.(true); + }, [onEditorFocusChange]); const handleBlur = useCallback(() => { setIsFocused(false); - }, []); + onEditorFocusChange?.(false); + }, [onEditorFocusChange]); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; @@ -623,6 +633,61 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer interactionMode: currentInteractionMode, }); + // iOS gets a native menu on the trigger pill: the everyday adjustments + // apply without resigning the keyboard, while "All Settings…" (and the + // Android trigger) still route through the sheet, which must dismiss it. + const settingsMenu = useMemo( + () => + Platform.OS === "ios" + ? buildThreadSettingsMenu({ + providerGroups: threadProviderGroups, + selectedModel: currentModelSelection, + optionDescriptors: providerOptionDescriptors, + runtimeMode: currentRuntimeMode, + }) + : null, + [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode], + ); + + const onUpdateModelSelection = props.onUpdateModelSelection; + const onUpdateRuntimeMode = props.onUpdateRuntimeMode; + const handleSettingsMenuAction = useCallback( + (eventId: string) => { + const event = settingsMenu?.events.get(eventId); + if (!event) { + return; + } + switch (event.type) { + case "select-model": + void Haptics.selectionAsync(); + onUpdateModelSelection(event.option.selection); + return; + case "set-option": { + const options = applyProviderOptionSelection(providerOptionDescriptors, { + id: event.optionId, + value: event.value, + }); + if (options) { + void Haptics.selectionAsync(); + onUpdateModelSelection({ ...currentModelSelection, options }); + } + return; + } + case "set-runtime": + void Haptics.selectionAsync(); + onUpdateRuntimeMode(event.mode); + return; + } + }, + [ + currentModelSelection, + onUpdateModelSelection, + onUpdateRuntimeMode, + providerOptionDescriptors, + settingsMenu, + ], + ); + return ( void props.onPickDraftImages()} showChevron={false} /> - - } - label={settingsSummaryLabel} - maxWidth={320} - onPress={settingsSheetPresentation.open} - /> + {settingsMenu ? ( + handleSettingsMenuAction(nativeEvent.event)} + > + + } + label={settingsSummaryLabel} + maxWidth={320} + /> + + ) : ( + + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> + )} {showStopAction ? ( ; - readonly activePendingUserInputAnswers: Record | null; + readonly activePendingUserInputAnswers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; @@ -93,7 +126,7 @@ export interface ThreadDetailScreenProps { ) => Promise; readonly onSelectUserInputOption: ( requestId: ApprovalRequestId, - questionId: string, + question: UserInputQuestion, label: string, ) => void; readonly onChangeUserInputCustomAnswer: ( @@ -175,8 +208,48 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray state.isVisible); + const liveKeyboardHeight = useKeyboardState((state) => state.height); + // Android can swallow the IME hide callbacks when the app is backgrounded + // mid keyboard-hide (the reported repro: send — which blurs and starts the + // hide — then Home within a second). The keyboard library's height AND + // visibility then stay frozen open, so gating the sticky translation on + // visibility alone still strands the composer after resume. Quarantine the + // translation on every Android resume instead; any sign of a live keyboard + // stream — an owned input gaining focus, or any visibility/height movement — + // lifts it. A healthy resume sees no visual difference (the translation is + // already zero while the keyboard is closed). + const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); + useEffect(() => { + if (Platform.OS !== "android") { + return; + } + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") { + setKeyboardStateSuspect(true); + } + }); + return () => { + subscription.remove(); + }; + }, []); + useEffect(() => { + setKeyboardStateSuspect(false); + }, [isKeyboardVisible, liveKeyboardHeight]); + const handleOwnedInputFocusChange = useCallback((focused: boolean) => { + if (focused) { + setKeyboardStateSuspect(false); + } + }, []); + const windowHeight = useWindowDimensions().height; + const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); @@ -187,11 +260,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); - // Key the safe-area padding on keyboard visibility, not focus: on Android - // the back gesture closes the keyboard while the editor stays focused, and - // a focus-keyed inset would leave the toolbar under the gesture bar. - const isKeyboardVisible = useKeyboardState((state) => state.isVisible); - const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12); + const [endFollowEnabled, setEndFollowEnabled] = useState(true); + // Android keys the safe-area padding on keyboard visibility (#5988): the + // back gesture closes the keyboard while the editor stays focused, and a + // focus-keyed inset would leave the toolbar under the gesture bar. iOS must + // NOT use visibility — it only flips on keyboardDidHide, after the hide + // animation, so the composer would ride down flush to the screen edge and + // then snap up into the inset. On iOS blur precedes the hide, so the + // focus-keyed inset is already in place while the composer rides down. + const composerBottomInset = (Platform.OS === "android" ? isKeyboardVisible : composerExpanded) + ? 0 + : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no @@ -212,6 +291,41 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; + // While a user-input request is pending, the questionnaire owns the + // composer slot outright: expanded it is the full card, collapsed it is a + // composer-style bar in the same place (with its own stop control). The + // composer never mounts into the transition, which keeps the collapse and + // keyboard animations coherent. Collapse state is keyed by request id so a + // new request re-expands automatically. + const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = + useState(null); + const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + const userInputCollapsed = + activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; + // The card's height RESERVES keyboard space at all times instead of + // tracking the keyboard: transforms (the sticky translation) apply + // same-frame on the UI thread while layout props lag a Yoga pass behind, + // so any height that follows the keyboard flashes the card over the nav + // header on the way up. With a constant height the keyboard transition is + // pure translation — frame-perfect by construction — and the resting card + // stays compact over the transcript. Before the first open the reserve is + // an estimate; once a real height is known the card corrects once, + // discretely. + const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); + useEffect(() => { + if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { + setLastKnownKeyboardHeight(liveKeyboardHeight); + } + }, [lastKnownKeyboardHeight, liveKeyboardHeight]); + const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ + windowHeight, + keyboardHeight: + lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT, + navigationHeaderHeight, + // The questionnaire owns the composer slot, so only the composer's + // bottom inset still overlaps. + composerOverlapHeight: composerBottomInset, + }); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -228,7 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), -nativeInsetOvercount, ); + // The expanded questionnaire is an absolute overlay on iOS, so it never + // changes the measured overlay height (that constancy is what keeps the + // feed from snapping on collapse/expand). The toggle choreography runs on + // SHARED VALUES set directly in the tap handler — one JS hop, then the + // card's rise/sink and the feed's end-inset glide animate in lockstep on + // the UI thread, keyboard-style, instead of waiting on React mount + + // onLayout + state round trips. Coverage (how far the card extends above + // the bar) is measured straight into a shared value by the card's + // onLayout, with no re-render. + const userInputCardProgress = useSharedValue(1); + const userInputInsetProgress = useSharedValue(1); + const userInputCardCoverage = useSharedValue(0); + // Android renders the expanded card in-flow (it cannot hit-test the iOS + // overlay outside the bar's bounds), so its measured overlay height already + // includes the card — the coverage extra is iOS-only. + const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null; + const combinedContentInsetEndAdjustment = useSharedValue( + Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), + ); + useAnimatedReaction( + () => + contentInsetEndAdjustment.value + + (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), + (value) => { + combinedContentInsetEndAdjustment.value = value; + }, + [userInputCoverageApplies], + ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); + const endFollowEnabledRef = useRef(true); + endFollowEnabledRef.current = endFollowEnabled; + const userInputRepinTimerRef = useRef | null>(null); + // The list's own corrections for these inset changes drift on short + // content (and the error compounds across toggles), so deterministically + // re-pin the end once a toggle settles: a no-op when the resting position + // is already right, corrective when it is not. Follow state is re-checked + // inside the callback — the user may grab the list during the settle + // window, and yanking them back would override a live gesture. + const scheduleUserInputRepin = useCallback( + (delayMs: number) => { + if (userInputRepinTimerRef.current !== null) { + clearTimeout(userInputRepinTimerRef.current); + } + userInputRepinTimerRef.current = setTimeout(() => { + userInputRepinTimerRef.current = null; + if (!endFollowEnabledRef.current) { + return; + } + void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { + freeze.set(false); + }); + }, delayMs); + }, + [freeze, scrollMessageToEnd], + ); + useEffect( + () => () => { + if (userInputRepinTimerRef.current !== null) { + clearTimeout(userInputRepinTimerRef.current); + } + }, + [], + ); + const handleToggleUserInputCollapsed = useCallback(() => { + if (activeUserInputRequestId === null) { + return; + } + if (userInputCollapsed) { + // Expanding: card and feed glide start NOW, on the UI thread. + userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); + userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); + setCollapsedUserInputRequestId(null); + scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); + } else { + // Collapsing hides the custom-answer inputs; release the keyboard with + // them instead of leaving it up over a dead responder. + Keyboard.dismiss(); + userInputCardProgress.value = withTiming(0, USER_INPUT_TOGGLE_TIMING); + // Instant: the sinking card still covers the strip being revealed, and + // animating the inset downward is what drifted the short-content end + // anchor. + userInputInsetProgress.value = 0; + setCollapsedUserInputRequestId(activeUserInputRequestId); + scheduleUserInputRepin(60); + } + }, [ + activeUserInputRequestId, + scheduleUserInputRepin, + userInputCardProgress, + userInputCollapsed, + userInputInsetProgress, + ]); + useEffect(() => { + // A new request always arrives expanded. + userInputCardProgress.value = 1; + userInputInsetProgress.value = 1; + }, [activeUserInputRequestId, userInputCardProgress, userInputInsetProgress]); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; @@ -249,6 +459,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); lastScrolledAnchorMessageIdRef.current = null; + setEndFollowEnabled(true); freeze.set(false); }, [freeze, selectedThreadKey]); @@ -320,6 +531,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread composerEditorRef.current?.blur(); }, []); + const handleScrollToEnd = useCallback(() => { + void Haptics.selectionAsync(); + void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { + freeze.set(false); + }); + }, [freeze, scrollMessageToEnd]); + + const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; + const isDarkMode = useColorScheme() === "dark"; + const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { feedTouchStartRef.current = { pageX: event.nativeEvent.pageX, @@ -373,13 +594,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} - contentInsetEndAdjustment={contentInsetEndAdjustment} + contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} contentMaxWidth={contentMaxWidth} layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} + onEndFollowEnabledChange={setEndFollowEnabled} skills={selectedProviderSkills} loadEarlier={props.loadEarlier ?? null} /> @@ -391,6 +613,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( @@ -398,10 +624,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} + {showScrollToEndButton ? ( + + {isLiquidGlassSupported ? ( + + + + ) : ( + + )} + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( @@ -415,6 +691,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingUserInput ? ( - + {/* Hidden (not unmounted) while a user-input request owns the + composer slot, so composer drafts and editor state survive. */} + + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7933e4ca601..0bd57799fe9 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { @@ -89,6 +90,10 @@ import { type ThreadFeedLatestTurn, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { + resolveThreadFeedLiveFollow, + type ThreadFeedLiveFollowEvent, +} from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, ThreadWorkGroupToggle, @@ -149,6 +154,7 @@ export interface ThreadFeedProps { readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; + readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { @@ -1324,6 +1330,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); + const userScrollSettleTimerRef = useRef | null>(null); const { width: windowWidth } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); const [viewportWidth, setViewportWidth] = useState(() => @@ -1342,13 +1349,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // momentum; only motion inside a session can break follow, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); - const setEndFollow = useCallback((enabled: boolean) => { - if (endFollowEnabledRef.current === enabled) { - return; - } - endFollowEnabledRef.current = enabled; - setEndFollowEnabled(enabled); - }, []); + const setEndFollow = useCallback( + (enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + props.onEndFollowEnabledChange?.(enabled); + }, + [props.onEndFollowEnabledChange], + ); + const transitionEndFollow = useCallback( + (event: ThreadFeedLiveFollowEvent) => { + setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); + }, + [setEndFollow], + ); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1460,40 +1477,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the // header height back or the material toggles a full header too late. reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); - // Latch bookkeeping. LegendList recomputes its inset-aware end distance - // before invoking this handler, so getState() is current. Returning to - // the end re-arms follow no matter who scrolled (the user, or our own - // scroll-to-end); moving away breaks it only during a user-initiated - // scroll session, so MVCP compensations and programmatic repositioning - // can never strand a follower. + // LegendList recomputes its inset-aware end distance before invoking + // this handler, so getState() is current. Only the actual end re-arms + // follow: its broader maintain-scroll threshold is large enough for a + // streaming chunk to pull a user back before their upward drag escapes. + // A live user-scroll session still wins even if the first scroll event + // remains inside LegendList's at-end tolerance. const listState = props.listRef.current?.getState(); if (listState) { - if (listState.isWithinMaintainScrollAtEndThreshold) { - setEndFollow(true); - } else if (userScrollSessionRef.current) { - setEndFollow(false); - } + transitionEndFollow({ + type: "scroll", + isAtEnd: listState.isAtEnd, + userScrollSessionActive: userScrollSessionRef.current, + }); } }, - [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, setEndFollow], + [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], ); + const clearUserScrollSettle = useCallback(() => { + if (userScrollSettleTimerRef.current !== null) { + clearTimeout(userScrollSettleTimerRef.current); + userScrollSettleTimerRef.current = null; + } + }, []); const handleScrollBeginDrag = useCallback(() => { + clearUserScrollSettle(); userScrollSessionRef.current = true; - }, []); - // The session must survive past finger-lift so momentum that carries the - // user away from the end still breaks follow; a drag released with no - // momentum ends its session at the release itself, otherwise at momentum - // end. Leaving a session open would let a later animated maintain-scroll - // read as user motion and break follow spuriously. - const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent) => { - const velocity = event.nativeEvent.velocity?.y ?? 0; - if (Math.abs(velocity) < 0.05) { + // Pause before the first scroll event. Otherwise a stream update can run + // maintainScrollAtEnd between touch-down and the drag leaving its threshold. + transitionEndFollow({ type: "user-scroll-begin" }); + }, [clearUserScrollSettle, transitionEndFollow]); + const finishUserScroll = useCallback( + (releaseIsAtEnd?: boolean) => { + clearUserScrollSettle(); + const userScrollSessionActive = userScrollSessionRef.current; userScrollSessionRef.current = false; + transitionEndFollow({ + type: "user-scroll-end", + // With no momentum, preserve the finger-release position. Streaming + // growth during the native momentum-detection window must not turn a + // release at the live edge into an opt-out from follow. + isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false, + userScrollSessionActive, + }); + }, + [clearUserScrollSettle, props.listRef, transitionEndFollow], + ); + // Finger-lift velocity is not a reliable momentum signal: a gentle fling + // can report zero and still decelerate. Give native momentum a short window + // to announce itself; if it does, onMomentumScrollBegin cancels this fallback + // and the session survives until the settled momentum-end position. This + // mirrors the native-event handoff used by the home thread list's scroll gate. + const handleScrollEndDrag = useCallback(() => { + clearUserScrollSettle(); + const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false; + userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160); + }, [clearUserScrollSettle, finishUserScroll, props.listRef]); + const handleMomentumScrollBegin = useCallback(() => { + if (userScrollSessionRef.current) { + clearUserScrollSettle(); } - }, []); + }, [clearUserScrollSettle]); const handleMomentumScrollEnd = useCallback(() => { - userScrollSessionRef.current = false; - }, []); + finishUserScroll(); + }, [finishUserScroll]); + + useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); @@ -1502,23 +1551,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); + // Thread identity is env-scoped: two environments can hold the same + // ThreadId, and keying resets (or the list mount) on the bare id would + // carry stale scroll/follow state across an environment switch. + const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); + useEffect(() => { reportHeaderMaterialVisibility(false); - }, [props.threadId, reportHeaderMaterialVisibility]); + }, [feedThreadKey, reportHeaderMaterialVisibility]); // A thread switch opens pinned to the end; a send explicitly returns to the // live edge (ThreadDetailScreen scrolls the new message into place). Both // re-arm follow regardless of where the user had scrolled before. useEffect(() => { + clearUserScrollSettle(); userScrollSessionRef.current = false; - setEndFollow(true); - }, [props.threadId, setEndFollow]); + transitionEndFollow({ type: "reset" }); + }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); useEffect(() => { if (props.anchorMessageId !== null) { + clearUserScrollSettle(); userScrollSessionRef.current = false; - setEndFollow(true); + transitionEndFollow({ type: "reset" }); } - }, [props.anchorMessageId, setEndFollow]); + }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); @@ -1554,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // initial scroll-to-end computes with a zero end inset and rests one // composer-height short of the end. Layout effect: it must land before the // list's first positioning tick or the one-shot initial scroll misses it. - const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -1921,6 +1977,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onScroll={handleScroll} onScrollBeginDrag={handleScrollBeginDrag} onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} ListHeaderComponent={ diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 9c27e6f01c5..f87a41e0eef 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -30,6 +30,7 @@ import { cn } from "../../lib/cn"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; import { useThemeColor } from "../../lib/useThemeColor"; +import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; import { pendingModelAfterPress } from "./thread-settings-sheet-state"; import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; @@ -40,26 +41,6 @@ import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet */ const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set(["claudeAgent", "codex"]); -/** - * Desktop-oriented effort keywords that don't belong in the phone picker. - * Prompt-injected values (ultrathink and friends) are filtered from the - * descriptor metadata; ultracode is a real option but a workflow trigger, not - * a reasoning level. A value set elsewhere still displays, it just isn't - * offered. - */ -const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); - -const RUNTIME_MODE_CHOICES: ReadonlyArray<{ - readonly mode: RuntimeMode; - readonly label: string; - readonly shortLabel: string; -}> = [ - { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, - { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, - { mode: "auto", label: "Auto", shortLabel: "Auto" }, - { mode: "full-access", label: "Full access", shortLabel: "Full" }, -]; - /** * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, * covering model, provider options, runtime mode, and plan mode in one label. @@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: { ].join(" · "); } -function selectableChoices(descriptor: Extract) { - const injected = new Set(descriptor.promptInjectedValues ?? []); - return descriptor.options.filter( - (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), - ); -} - function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts new file mode 100644 index 00000000000..8dd15ccc8d8 --- /dev/null +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout"; + +describe("derivePendingUserInputMaxHeight", () => { + it("caps a tall portrait viewport", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 932, + keyboardHeight: 0, + navigationHeaderHeight: 103, + composerOverlapHeight: 94, + }), + ).toBe(560); + }); + + it("subtracts the keyboard while editing a custom answer", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 932, + keyboardHeight: 336, + navigationHeaderHeight: 103, + composerOverlapHeight: 94, + }), + ).toBe(387); + }); + + it("keeps the fixed action area usable in a short keyboard-open viewport", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 375, + keyboardHeight: 240, + navigationHeaderHeight: 44, + composerOverlapHeight: 94, + }), + ).toBe(160); + }); +}); diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts new file mode 100644 index 00000000000..56924617e56 --- /dev/null +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -0,0 +1,37 @@ +const PENDING_USER_INPUT_MAX_HEIGHT = 560; +const PENDING_USER_INPUT_MIN_HEIGHT = 160; +const PENDING_USER_INPUT_VERTICAL_GAP = 12; + +/** + * Reserve for a portrait iPhone keyboard with the QuickType bar until a real + * height has been observed. Overestimating only costs card height; an + * underestimate would let the card overshoot on the first keyboard open. + */ +export const ESTIMATED_KEYBOARD_HEIGHT = 336; + +/** + * One clock for the questionnaire expand/collapse choreography: the card's + * enter/exit and the feed-inset glide must share it or they visibly drift. + * Sized for the near-full-height slide (the card travels its own height), + * in the same class as the iOS keyboard's ~250ms. + */ +export const USER_INPUT_TOGGLE_DURATION_MS = 220; + +export function derivePendingUserInputMaxHeight(input: { + readonly windowHeight: number; + readonly keyboardHeight: number; + readonly navigationHeaderHeight: number; + readonly composerOverlapHeight: number; +}): number { + const availableHeight = + input.windowHeight - + Math.max(0, input.keyboardHeight) - + Math.max(0, input.navigationHeaderHeight) - + Math.max(0, input.composerOverlapHeight) - + PENDING_USER_INPUT_VERTICAL_GAP; + + return Math.min( + PENDING_USER_INPUT_MAX_HEIGHT, + Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight), + ); +} diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts new file mode 100644 index 00000000000..8cc68cb3c52 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; + +describe("resolveThreadFeedLiveFollow", () => { + it("pauses immediately when the user starts scrolling", () => { + expect(resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" })).toBe(false); + }); + + it("stays paused away from the actual end", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "scroll", + isAtEnd: false, + userScrollSessionActive: true, + }), + ).toBe(false); + }); + + it("does not mistake programmatic layout compensation for a user scroll", () => { + expect( + resolveThreadFeedLiveFollow(true, { + type: "scroll", + isAtEnd: false, + userScrollSessionActive: false, + }), + ).toBe(true); + }); + + it("does not re-arm at the end while a user scroll session is active", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "scroll", + isAtEnd: true, + userScrollSessionActive: true, + }), + ).toBe(false); + }); + + it("re-arms at the actual end only after the user scroll session ends", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "user-scroll-end", + isAtEnd: true, + userScrollSessionActive: true, + }), + ).toBe(true); + expect( + resolveThreadFeedLiveFollow(false, { + type: "user-scroll-end", + isAtEnd: false, + userScrollSessionActive: true, + }), + ).toBe(false); + }); + + it("ignores momentum-end events from programmatic scrolling", () => { + expect( + resolveThreadFeedLiveFollow(true, { + type: "user-scroll-end", + isAtEnd: false, + userScrollSessionActive: false, + }), + ).toBe(true); + }); + + it("re-arms after an explicit reset", () => { + expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts new file mode 100644 index 00000000000..babe18f0c1c --- /dev/null +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -0,0 +1,35 @@ +export type ThreadFeedLiveFollowEvent = + | { readonly type: "reset" } + | { readonly type: "user-scroll-begin" } + | { + readonly type: "user-scroll-end"; + readonly isAtEnd: boolean; + readonly userScrollSessionActive: boolean; + } + | { + readonly type: "scroll"; + readonly isAtEnd: boolean; + readonly userScrollSessionActive: boolean; + }; + +export function resolveThreadFeedLiveFollow( + current: boolean, + event: ThreadFeedLiveFollowEvent, +): boolean { + switch (event.type) { + case "reset": + return true; + case "user-scroll-begin": + return false; + case "user-scroll-end": + return event.userScrollSessionActive ? event.isAtEnd : current; + case "scroll": + if (event.userScrollSessionActive) { + return false; + } + if (event.isAtEnd) { + return true; + } + return current; + } +} diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts new file mode 100644 index 00000000000..078be2df11b --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-menu.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; + +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; +import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; + +function modelOption( + model: string, + overrides: Partial> = {}, +): ModelOption { + const providerKey = overrides.providerKey ?? "codex"; + return { + key: `${providerKey}:${model}`, + label: model, + subtitle: providerKey, + providerKey, + providerLabel: providerKey === "codex" ? "Codex" : "Claude", + providerDriver: providerKey === "codex" ? "codex" : "claudeAgent", + isDefault: overrides.isDefault ?? false, + isLegacy: overrides.isLegacy ?? false, + capabilities: null, + selection: { + instanceId: ProviderInstanceId.make(providerKey), + model, + options: [], + }, + }; +} + +function group(models: ReadonlyArray): ProviderGroup { + const first = models[0]; + if (!first) { + throw new Error("group requires at least one model"); + } + return { + providerKey: first.providerKey, + providerLabel: first.providerLabel, + models, + }; +} + +const effortDescriptor: ProviderOptionDescriptor = { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + { id: "ultracode", label: "Ultracode" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], +}; + +const fastModeDescriptor: ProviderOptionDescriptor = { + id: "fastMode", + label: "Fast mode", + type: "boolean", + currentValue: false, +}; + +function baseInput() { + const models = [ + modelOption("gpt-current", { isDefault: true }), + modelOption("gpt-next"), + modelOption("gpt-old", { isLegacy: true }), + ]; + return { + providerGroups: [group(models)], + selectedModel: models[0]?.selection ?? null, + optionDescriptors: [effortDescriptor, fastModeDescriptor], + runtimeMode: "auto", + } as const; +} + +function eventFor(menu: ReturnType, id: string | undefined) { + return id === undefined ? undefined : menu.events.get(id); +} + +describe("buildThreadSettingsMenu", () => { + it("orders the top level as model, options, runtime", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + expect(menu.actions.map((action) => action.title)).toEqual([ + "Model", + "Reasoning", + "Fast mode", + "Runtime", + ]); + }); + + it("summarizes the current choice on each submenu row", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current"); + expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High"); + expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto"); + }); + + it("checkmarks the selected model and resolves selection events", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + const current = modelItems.find((action) => action.title === "gpt-current"); + expect(current?.state).toBe("on"); + expect(current?.subtitle).toBe("Default"); + expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off"); + + const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id); + expect(event?.type).toBe("select-model"); + expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next"); + }); + + it("folds unselected legacy models behind a nested submenu", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect(modelItems.map((action) => action.title)).toEqual([ + "gpt-current", + "gpt-next", + "Legacy Models", + ]); + expect( + modelItems + .find((action) => action.title === "Legacy Models") + ?.subactions?.map((action) => action.title), + ).toEqual(["gpt-old"]); + }); + + it("keeps a selected legacy model in the main list", () => { + const input = baseInput(); + const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy); + const menu = buildThreadSettingsMenu({ + ...input, + selectedModel: legacy?.selection ?? null, + }); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect(modelItems.map((action) => action.title)).toEqual([ + "gpt-current", + "gpt-next", + "gpt-old", + ]); + expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on"); + }); + + it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => { + const menu = buildThreadSettingsMenu({ + ...baseInput(), + optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }], + }); + + const reasoning = menu.actions.find((action) => action.title === "Reasoning"); + expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]); + // The hidden value stays visible as the current summary; it just can't be + // picked from the phone. + expect(reasoning?.subtitle).toBe("Ultracode"); + expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true); + }); + + it("resolves select-option and runtime events with checkmarked current values", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const reasoningItems = + menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; + expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on"); + expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({ + type: "set-option", + optionId: "effort", + value: "low", + }); + + const runtimeItems = + menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; + expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on"); + expect( + eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id), + ).toEqual({ type: "set-runtime", mode: "full-access" }); + }); + + it("toggles boolean options with the inverted current value", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const fastMode = menu.actions.find((action) => action.title === "Fast mode"); + expect(fastMode?.state).toBe("off"); + expect(fastMode?.subactions).toBeUndefined(); + expect(eventFor(menu, fastMode?.id)).toEqual({ + type: "set-option", + optionId: "fastMode", + value: true, + }); + + const enabled = buildThreadSettingsMenu({ + ...baseInput(), + optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }], + }); + const enabledRow = enabled.actions.find((action) => action.title === "Fast mode"); + expect(enabledRow?.state).toBe("on"); + expect(eventFor(enabled, enabledRow?.id)).toEqual({ + type: "set-option", + optionId: "fastMode", + value: false, + }); + }); + + it("keeps the menu presented only for top-level toggles", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + // Root-level boolean toggles refresh in place with clean chrome, so they + // keep the menu presented. + expect( + menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, + ).toBe(true); + + // Picks inside nested submenus close the menu: staying presented leaves + // the submenu on screen with an expanded-submenu header, and the + // bottom-anchored collapse back out drops by the levels' height delta. + const expected = undefined; + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + const reasoningItems = + menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; + const runtimeItems = + menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; + const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter( + (action) => action.subactions === undefined, + ); + expect(nestedPicks.length).toBeGreaterThan(0); + expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe( + true, + ); + }); + + it("sections models by provider only when multiple groups are offered", () => { + const codexModels = [modelOption("gpt-current", { isDefault: true })]; + const claudeModels = [modelOption("fable-5", { providerKey: "claude" })]; + const menu = buildThreadSettingsMenu({ + providerGroups: [group(codexModels), group(claudeModels)], + selectedModel: codexModels[0]?.selection ?? null, + optionDescriptors: [], + runtimeMode: "auto", + }); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect( + modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })), + ).toEqual([ + { title: "Codex", inline: true }, + { title: "Claude", inline: true }, + ]); + const claudeSection = modelItems.find((action) => action.title === "Claude"); + expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]); + }); + + const eventTypes = (menu: ReturnType) => { + const types = new Set(); + for (const event of menu.events.values()) { + types.add(event.type); + } + return types; + }; + + it("registers an event for every leaf action id", () => { + const menu = buildThreadSettingsMenu(baseInput()); + const leafIds: string[] = []; + const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => { + for (const item of items) { + if (Array.isArray(item.subactions) && item.subactions.length > 0) { + collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>); + } else if (item.id !== undefined) { + leafIds.push(item.id); + } + } + }; + collect(menu.actions); + + for (const id of leafIds) { + expect(menu.events.get(id), `missing event for ${id}`).toBeDefined(); + } + expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"])); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts new file mode 100644 index 00000000000..31b1c021c46 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-menu.ts @@ -0,0 +1,202 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; +import { + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, +} from "@t3tools/shared/model"; + +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; + +/** + * Desktop-oriented effort keywords that don't belong in the phone picker. + * Prompt-injected values (ultrathink and friends) are filtered from the + * descriptor metadata; ultracode is a real option but a workflow trigger, not + * a reasoning level. A value set elsewhere still displays, it just isn't + * offered. + */ +export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); + +export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ + readonly mode: RuntimeMode; + readonly label: string; + readonly shortLabel: string; +}> = [ + { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, + { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, + { mode: "auto", label: "Auto", shortLabel: "Auto" }, + { mode: "full-access", label: "Full access", shortLabel: "Full" }, +]; + +export function selectableChoices( + descriptor: Extract, +) { + const injected = new Set(descriptor.promptInjectedValues ?? []); + return descriptor.options.filter( + (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), + ); +} + +export type ThreadSettingsMenuEvent = + | { readonly type: "select-model"; readonly option: ModelOption } + | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } + | { readonly type: "set-runtime"; readonly mode: RuntimeMode }; + +export type ThreadSettingsMenu = { + readonly actions: MenuAction[]; + /** Menu action id → the change it applies, for the onPressAction dispatch. */ + readonly events: ReadonlyMap; +}; + +/** + * Native menu replacement for the thread settings sheet (model, select and + * boolean provider options, runtime mode). The menu presents from the + * composer pill without resigning the keyboard, so adjusting settings never + * bounces focus. A thread is bound to one harness, so the menu covers the + * sheet's full surface for existing threads; the sheet remains the Android + * and new-task-draft surface. + * + * Selections apply immediately — the sheet's stage-then-Save flow only exists + * because the sheet batches a model change with its option edits. + */ +export function buildThreadSettingsMenu(input: { + readonly providerGroups: ReadonlyArray; + readonly selectedModel: ModelSelection | null; + readonly optionDescriptors: ReadonlyArray; + readonly runtimeMode: RuntimeMode; +}): ThreadSettingsMenu { + const events = new Map(); + const actions: MenuAction[] = []; + + const isSelected = (option: ModelOption) => + option.selection.instanceId === input.selectedModel?.instanceId && + option.selection.model === input.selectedModel.model; + + // Only top-level leaves (boolean toggles) keep the menu presented (iOS + // 16+): the root refreshes in place with clean chrome. Picks inside nested + // submenus close the menu — keeping the submenu presented renders an + // expanded-submenu header with no way to pop back to the root, and the + // bottom-anchored collapse back out travels the levels' height difference. + const keepPresented = { keepsMenuPresented: true } as const; + + const modelAction = (option: ModelOption, id: string): MenuAction => { + events.set(id, { type: "select-model", option }); + return { + id, + title: option.label, + ...(option.isDefault ? { subtitle: "Default" } : {}), + state: isSelected(option) ? "on" : "off", + }; + }; + + const modelItems: MenuAction[] = []; + const legacyItems: MenuAction[] = []; + let selectedModelLabel: string | undefined; + input.providerGroups.forEach((group, groupIndex) => { + const groupItems: MenuAction[] = []; + group.models.forEach((option, modelIndex) => { + if (isSelected(option)) { + selectedModelLabel = option.label; + } + const id = `model:${groupIndex}:${modelIndex}`; + // A highlighted legacy model stays in the main list (mirroring the + // sheet) so the checkmark isn't hidden behind the Legacy fold. + if (option.isLegacy && !isSelected(option)) { + legacyItems.push(modelAction(option, id)); + } else { + groupItems.push(modelAction(option, id)); + } + }); + if (groupItems.length === 0) { + return; + } + // A thread is bound to one harness, so provider sections only appear for + // multi-group callers (the new-task draft, if it ever adopts the menu). + if (input.providerGroups.length > 1) { + modelItems.push({ + id: `model-group:${groupIndex}`, + title: group.providerLabel, + displayInline: true, + subactions: groupItems, + }); + } else { + modelItems.push(...groupItems); + } + }); + if (legacyItems.length > 0) { + modelItems.push({ + id: "legacy-models", + title: "Legacy Models", + subactions: legacyItems, + }); + } + if (modelItems.length > 0) { + actions.push({ + id: "model", + title: "Model", + ...(selectedModelLabel === undefined + ? input.selectedModel + ? { subtitle: input.selectedModel.model } + : {} + : { subtitle: selectedModelLabel }), + subactions: modelItems, + }); + } + + for (const descriptor of input.optionDescriptors) { + if (descriptor.type === "boolean") { + const id = `option:${descriptor.id}`; + events.set(id, { + type: "set-option", + optionId: descriptor.id, + value: !(descriptor.currentValue ?? false), + }); + actions.push({ + id, + title: descriptor.label, + state: descriptor.currentValue ? "on" : "off", + attributes: keepPresented, + }); + continue; + } + const currentValue = getProviderOptionCurrentValue(descriptor); + const choices = selectableChoices(descriptor).map((choice): MenuAction => { + const id = `option:${descriptor.id}:${choice.id}`; + events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id }); + return { + id, + title: choice.label, + state: choice.id === currentValue ? "on" : "off", + }; + }); + if (choices.length === 0) { + continue; + } + const currentLabel = getProviderOptionCurrentLabel(descriptor); + actions.push({ + id: `option:${descriptor.id}`, + title: descriptor.label, + ...(currentLabel === undefined ? {} : { subtitle: currentLabel }), + subactions: choices, + }); + } + + const runtimeLabel = RUNTIME_MODE_CHOICES.find( + (choice) => choice.mode === input.runtimeMode, + )?.label; + actions.push({ + id: "runtime", + title: "Runtime", + ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }), + subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => { + const id = `runtime:${choice.mode}`; + events.set(id, { type: "set-runtime", mode: choice.mode }); + return { + id, + title: choice.label, + state: choice.mode === input.runtimeMode ? "on" : "off", + }; + }), + }); + + return { actions, events }; +} diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index 474c99668cd..4926ae65ca3 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -32,6 +32,19 @@ function makeUpdateClient(overrides: Partial = {}): AppUpdateCl } describe("runAppUpdateCheck", () => { + it("does nothing while running from the Metro development server", async () => { + vi.stubGlobal("__DEV__", true); + const client = makeUpdateClient(); + + try { + await runAppUpdateCheck({ client }); + } finally { + vi.unstubAllGlobals(); + } + + expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); + }); + it("downloads and restarts when a new update is available", async () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => ({ @@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => { reportError.mockRestore(); }); + it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])( + "treats Expo's %s failure as an unavailable update check", + async (code) => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const error = Object.assign(new Error("Updates are unavailable"), { code }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => { + throw error; + }), + }); + const failures: string[] = []; + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ + client, + onFailure: (message) => failures.push(message), + onStateChange: (state) => states.push(state), + }); + + expect(reportError).not.toHaveBeenCalled(); + expect(failures).toEqual([]); + expect(states).toEqual(["checking", "idle"]); + reportError.mockRestore(); + }, + ); + it("coalesces overlapping launch and manual checks", async () => { let resolveCheck!: (result: { readonly isAvailable: boolean; diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index ab896b53c07..66525d02292 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -48,8 +48,17 @@ interface Deferred { } const HIDDEN_UPDATE_TAP_COUNT = 5; +const UPDATE_CHECK_UNAVAILABLE_ERROR_CODES = new Set([ + "ERR_NOT_AVAILABLE_IN_DEV_CLIENT", + "ERR_UPDATES_DISABLED", +]); let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; +/** Expo's development launcher reports updates as enabled even though its OTA APIs reject. */ +export function isAppUpdateCheckAvailable(client: Pick = Updates) { + return client.isEnabled && !(typeof __DEV__ !== "undefined" && __DEV__); +} + /** * Keeps the manual update affordance discoverable only to someone deliberately * tapping the version row five times. @@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): { export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise { const client = options.client ?? Updates; - if (!client.isEnabled) return; + if (!isAppUpdateCheckAvailable(client)) return; if (appUpdateCheckInFlight) { await observeAppUpdateCheck(appUpdateCheckInFlight, options); @@ -207,19 +216,27 @@ function reportUpdateFailure( fallback: string, onFailure: AppUpdateCheckOptions["onFailure"], ): void { - reportAtomCommandResult(result, { label: "app update check" }); if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); + if (isAppUpdateUnavailableError(error)) return; + + reportAtomCommandResult(result, { label: "app update check" }); onFailure?.(error instanceof Error ? error.message : fallback); } +function isAppUpdateUnavailableError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) return false; + const code = error.code; + return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code); +} + export function createAppUpdateLaunchCheck( client: AppUpdateClient = Updates, ): () => Promise | undefined { let started = false; return () => { - if (started || !client.isEnabled) return undefined; + if (started || !isAppUpdateCheckAvailable(client)) return undefined; started = true; return runAppUpdateCheck({ client }); }; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ae9a93e9fc3..e1d46fd858e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -12,12 +12,107 @@ import { } from "@t3tools/contracts"; import { + buildPendingUserInputAnswers, buildThreadFeed, deriveThreadFeedPresentation, + isPendingUserInputOptionSelected, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; +const singleSelectQuestion = { + id: "runtime", + header: "Runtime", + question: "Which runtime should be used?", + options: [ + { label: "Go", description: "One binary" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const multiSelectQuestion = { + id: "scope", + header: "Scope", + question: "Which data should be collected?", + options: [ + { label: "Orders", description: "Receipts" }, + { label: "Listings", description: "Inventory" }, + ], + multiSelect: true, +} as const; + +describe("pending user input answers", () => { + it("replaces single-select options and toggles multi-select options", () => { + expect( + togglePendingUserInputOptionSelection( + singleSelectQuestion, + { selectedOptionLabels: ["Go"] }, + "Node.js", + ), + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] }); + + const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); + const ordersAndListings = togglePendingUserInputOptionSelection( + multiSelectQuestion, + orders, + "Listings", + ); + expect(ordersAndListings).toEqual({ + customAnswer: "", + selectedOptionLabels: ["Orders", "Listings"], + }); + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] }); + + const paddedOrders = togglePendingUserInputOptionSelection( + multiSelectQuestion, + undefined, + " Orders ", + ); + expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] }); + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), + ).toEqual({ customAnswer: "" }); + }); + + it("builds array answers for multi-select questions", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { + runtime: { selectedOptionLabels: ["Go"] }, + scope: { selectedOptionLabels: ["Orders", "Listings"] }, + }), + ).toEqual({ + runtime: "Go", + scope: ["Orders", "Listings"], + }); + }); + + it("clears selected options while a custom answer is active", () => { + expect( + setPendingUserInputCustomAnswer( + { selectedOptionLabels: ["Orders", "Listings"] }, + "Orders first", + ), + ).toEqual({ customAnswer: "Orders first" }); + }); + + it("matches selected chips against normalized option labels", () => { + expect( + isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "), + ).toBe(true); + expect( + isPendingUserInputOptionSelected( + { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" }, + " Orders ", + ), + ).toBe(false); + }); +}); + function makeActivity( input: Partial & Pick, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cd8e8cad212..fbcb2e1c7e2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -26,7 +26,7 @@ export interface PendingUserInput { } export interface PendingUserInputDraftAnswer { - readonly selectedOptionLabel?: string; + readonly selectedOptionLabels?: ReadonlyArray; readonly customAnswer?: string; } @@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeSelectedOptionLabels( + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), + ); +} + function resolvePendingUserInputAnswer( + question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, -): string | null { +): string | ReadonlyArray | null { const customAnswer = normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } - return normalizeDraftAnswer(draft?.selectedOptionLabel); + + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + if (question.multiSelect) { + return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; + } + return selectedOptionLabels[0] ?? null; } /** Codex children settle via task.updated (idle/failed/interrupted), never @@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabel = - customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + const selectedOptionLabels = + customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); return { customAnswer, - ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + }; +} + +export function isPendingUserInputOptionSelected( + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): boolean { + if (normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim()); +} + +export function togglePendingUserInputOptionSelection( + question: UserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): PendingUserInputDraftAnswer { + const normalizedOptionLabel = optionLabel.trim(); + + if (question.multiSelect) { + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel) + ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel) + : [...selectedOptionLabels, normalizedOptionLabel]; + + return { + customAnswer: "", + ...(nextSelectedOptionLabels.length > 0 + ? { selectedOptionLabels: nextSelectedOptionLabels } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionLabels: [normalizedOptionLabel], }; } export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, -): Record | null { - const answers: Record = {}; +): Record> | null { + const answers: Record> = {}; for (const question of questions) { - const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) { return null; } diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 82ff42f247a..30b3a0704f8 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { + ApprovalRequestId, + type ProviderApprovalDecision, + type UserInputQuestion, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { threadEnvironment } from "../state/threads"; @@ -12,6 +16,7 @@ import { derivePendingUserInputs, setPendingUserInputCustomAnswer, sortThreadActivities, + togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; import { appAtomRegistry } from "./atom-registry"; @@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); -function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { +function setUserInputDraftOption( + requestKey: string, + question: UserInputQuestion, + label: string, +): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...current, [requestKey]: { ...current[requestKey], - [questionId]: { - selectedOptionLabel: label, - }, + [question.id]: togglePendingUserInputOptionSelection( + question, + current[requestKey]?.[question.id], + label, + ), }, }); } @@ -97,13 +108,13 @@ export function useSelectedThreadRequests() { : null; const onSelectUserInputOption = useCallback( - (requestId: ApprovalRequestId, questionId: string, label: string) => { + (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => { if (!selectedThreadShell) { return; } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); - setUserInputDraftOption(requestKey, questionId, label); + setUserInputDraftOption(requestKey, question, label); }, [selectedThreadShell], ); diff --git a/apps/web/package.json b/apps/web/package.json index dfec330a107..68f6847b016 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.3.3", + "@legendapp/list": "catalog:", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/patches/@clerk__expo@4.2.0.patch b/patches/@clerk__expo@4.2.0.patch new file mode 100644 index 00000000000..2d4a9287c11 --- /dev/null +++ b/patches/@clerk__expo@4.2.0.patch @@ -0,0 +1,79 @@ +diff --git a/ios/ClerkAuthNativeView.swift b/ios/ClerkAuthNativeView.swift +index e76a8be1b1c8faa64ec6dfa83764764094133aff..17b36ad1319765e2b6db0551d32e07d7140e482f 100644 +--- a/ios/ClerkAuthNativeView.swift ++++ b/ios/ClerkAuthNativeView.swift +@@ -108,7 +108,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { + + override func makeHostedController() -> UIViewController? { + let hostBackAction: (() -> Void)? = currentHostBackButton +- ? { [weak self] in self?.onHostBack([:]) } ++ ? { [weak self] in ++ guard let self else { return } ++ if !self.popEnclosingNavigationRoute() { ++ self.onHostBack([:]) ++ } ++ } + : nil + + return ClerkNativeBridge.shared.makeAuthViewController( +diff --git a/ios/ClerkNativeViewHost.swift b/ios/ClerkNativeViewHost.swift +index 0d91f0e749f121595c17bc803663df6ac90e4163..8f8a89df97a54168b8b45d0e9c197ca3953b7028 100644 +--- a/ios/ClerkNativeViewHost.swift ++++ b/ios/ClerkNativeViewHost.swift +@@ -5,6 +5,7 @@ public class ClerkNativeViewHost: ExpoView { + private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) + private var hasInitialized: Bool = false + private var configuredObserver: NSObjectProtocol? ++ private var isPoppingHostRoute = false + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) +@@ -58,6 +59,30 @@ public class ClerkNativeViewHost: ExpoView { + + func hostedViewDidDetachFromWindow() {} + ++ /// Pops the React Navigation route that contains this view without waiting for ++ /// the JavaScript event loop. React Native Screens reports the native dismissal ++ /// back to React Navigation so its state remains synchronized. ++ func popEnclosingNavigationRoute() -> Bool { ++ guard !isPoppingHostRoute else { return true } ++ ++ var responder: UIResponder? = self ++ ++ while let nextResponder = responder?.next { ++ if let viewController = nextResponder as? UIViewController, ++ let navigationController = viewController.navigationController, ++ navigationController.viewControllers.count > 1 { ++ isPoppingHostRoute = true ++ if navigationController.popViewController(animated: true) != nil { ++ return true ++ } ++ isPoppingHostRoute = false ++ } ++ responder = nextResponder ++ } ++ ++ return false ++ } ++ + private func addConfiguredObserver() { + guard configuredObserver == nil else { return } + +diff --git a/ios/ClerkUserProfileNativeView.swift b/ios/ClerkUserProfileNativeView.swift +index 12d6248b1dc4b4779b252c9954c2f89145907310..d838283b7adcf49fec2a63bff53251b9bee31bf8 100644 +--- a/ios/ClerkUserProfileNativeView.swift ++++ b/ios/ClerkUserProfileNativeView.swift +@@ -44,7 +44,12 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { + + override func makeHostedController() -> UIViewController? { + let hostBackAction: (() -> Void)? = currentHostBackButton +- ? { [weak self] in self?.onHostBack([:]) } ++ ? { [weak self] in ++ guard let self else { return } ++ if !self.popEnclosingNavigationRoute() { ++ self.onHostBack([:]) ++ } ++ } + : nil + + return ClerkNativeBridge.shared.makeUserProfileViewController( diff --git a/patches/@legendapp__list@3.3.3.patch b/patches/@legendapp__list@3.3.5.patch similarity index 87% rename from patches/@legendapp__list@3.3.3.patch rename to patches/@legendapp__list@3.3.5.patch index 4fa135d5aa0..60a5954d8f2 100644 --- a/patches/@legendapp__list@3.3.3.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1,8 +1,8 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 7bc3bb8..75ec120 100644 +index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts -@@ -277,7 +277,7 @@ type KeyboardChatComposerInsetListRef = { +@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { type KeyboardChatComposerRef = { current: Pick | null; }; @@ -11,7 +11,7 @@ index 7bc3bb8..75ec120 100644 contentInsetEndAdjustment: SharedValue; onComposerLayout: (event: LayoutChangeEvent) => void; }; -@@ -286,8 +286,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb +@@ -288,8 +288,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise; }; declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { @@ -23,7 +23,7 @@ index 7bc3bb8..75ec120 100644 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 736286a..8218172 100644 +index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -62,8 +62,8 @@ index 736286a..8218172 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - includeInEndInset: true, +@@ -108,11 +110,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...anchoredEndSpace, onSizeChanged: (size) => { var _a; - blankSpace.value = size; @@ -80,7 +80,7 @@ index 736286a..8218172 100644 const onContentInsetChange = React.useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -123,6 +129,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( reactNativeKeyboardController.KeyboardChatScrollView, { ...scrollProps, @@ -88,7 +88,7 @@ index 736286a..8218172 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, extraContentPadding: contentInsetEndAdjustment, -@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -134,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -97,15 +97,15 @@ index 736286a..8218172 100644 blankSpace, contentInsetEndAdjustment, @@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, + anchoredEndSpaceOwnerInternal: "scroll", + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, ref: combinedRef, renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index c1dd270..cb0d142 100644 +index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { @@ -144,8 +144,8 @@ index c1dd270..cb0d142 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - includeInEndInset: true, +@@ -87,11 +89,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...anchoredEndSpace, onSizeChanged: (size) => { var _a; - blankSpace.value = size; @@ -162,7 +162,7 @@ index c1dd270..cb0d142 100644 const onContentInsetChange = useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -102,6 +108,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( KeyboardChatScrollView, { ...scrollProps, @@ -170,7 +170,7 @@ index c1dd270..cb0d142 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, extraContentPadding: contentInsetEndAdjustment, -@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -113,6 +120,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -179,15 +179,15 @@ index c1dd270..cb0d142 100644 blankSpace, contentInsetEndAdjustment, @@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, + anchoredEndSpaceOwnerInternal: "scroll", + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, ref: combinedRef, renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index 8204015..cdeaab7 100644 +index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -293,6 +293,12 @@ interface LegendListSpecificProps { @@ -204,10 +204,10 @@ index 8204015..cdeaab7 100644 * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index 229f09a..2a1ceb6 100644 +index b3c5a306b293f797a8b338adfca3060c0f6db22b..89077be4d6833cfaabf9d6d6205d9551505e32b4 100644 --- a/react-native.js +++ b/react-native.js -@@ -930,7 +930,7 @@ function setInitialRenderState(ctx, { +@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -216,7 +216,7 @@ index 229f09a..2a1ceb6 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1259,18 +1259,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1304,18 +1304,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -242,7 +242,7 @@ index 229f09a..2a1ceb6 100644 return clampedOffset; } -@@ -1406,10 +1411,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -255,19 +255,19 @@ index 229f09a..2a1ceb6 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1456,7 +1461,10 @@ function checkFinishedScrollFallback(ctx) { - }); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { -- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); -+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; -+ if (!isActivelyAnimatingToEnd) { -+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -+ } - scheduleFallbackCheck(100); - } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { - finishScrollTo(ctx); -@@ -1517,9 +1525,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1503,7 +1508,10 @@ function checkFinishedScrollFallback(ctx) { + ); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1566,9 +1574,18 @@ function doMaintainScrollAtEnd(ctx) { } if (shouldMaintainScrollAtEnd) { state.pendingMaintainScrollAtEnd = false; @@ -287,7 +287,7 @@ index 229f09a..2a1ceb6 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1539,9 +1556,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1591,9 +1608,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -309,7 +309,7 @@ index 229f09a..2a1ceb6 100644 } setTimeout( () => { -@@ -1571,6 +1597,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -320,7 +320,7 @@ index 229f09a..2a1ceb6 100644 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1674,7 +1704,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -331,7 +331,7 @@ index 229f09a..2a1ceb6 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1736,7 +1768,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1790,7 +1822,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -340,7 +340,7 @@ index 229f09a..2a1ceb6 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1869,7 +1901,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -349,7 +349,7 @@ index 229f09a..2a1ceb6 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2274,8 +2306,121 @@ function scrollToIndex(ctx, { +@@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -471,7 +471,7 @@ index 229f09a..2a1ceb6 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2704,7 +2849,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -482,9 +482,9 @@ index 229f09a..2a1ceb6 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4637,7 +4784,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { - } - contentBelowAnchor += footerSize + stylePaddingBottom; +@@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); + contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); @@ -492,20 +492,20 @@ index 229f09a..2a1ceb6 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4655,6 +4803,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateScroll(ctx, state.scroll, true); +@@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { + set$(ctx, "anchoredEndSpaceSize", nextSize); + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { -+ updateScroll(ctx, state.scroll, true); ++ updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); + } } return nextSize; } -@@ -6960,6 +7114,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7075,6 +7229,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -513,7 +513,7 @@ index 229f09a..2a1ceb6 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6990,6 +7145,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7105,6 +7260,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -521,7 +521,7 @@ index 229f09a..2a1ceb6 100644 onRefresh, onScroll: onScrollProp, onScrollBeginDrag, -@@ -7076,7 +7232,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7200,7 +7356,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -530,7 +530,7 @@ index 229f09a..2a1ceb6 100644 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7215,6 +7371,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7341,6 +7497,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -538,7 +538,7 @@ index 229f09a..2a1ceb6 100644 data: dataProp, dataKey, dataVersion, -@@ -7303,6 +7460,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7423,6 +7580,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -552,7 +552,7 @@ index 229f09a..2a1ceb6 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7526,6 +7690,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7651,6 +7815,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -560,7 +560,7 @@ index 229f09a..2a1ceb6 100644 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7555,6 +7720,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7681,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -569,10 +569,10 @@ index 229f09a..2a1ceb6 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index c2e0f38..5313086 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644 --- a/react-native.mjs +++ b/react-native.mjs -@@ -909,7 +909,7 @@ function setInitialRenderState(ctx, { +@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -581,7 +581,7 @@ index c2e0f38..5313086 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1238,18 +1238,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1283,18 +1283,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -607,7 +607,7 @@ index c2e0f38..5313086 100644 return clampedOffset; } -@@ -1385,10 +1390,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -620,19 +620,19 @@ index c2e0f38..5313086 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1435,7 +1440,10 @@ function checkFinishedScrollFallback(ctx) { - }); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { -- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); -+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; -+ if (!isActivelyAnimatingToEnd) { -+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -+ } - scheduleFallbackCheck(100); - } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { - finishScrollTo(ctx); -@@ -1496,9 +1504,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1482,7 +1487,10 @@ function checkFinishedScrollFallback(ctx) { + ); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1545,9 +1553,18 @@ function doMaintainScrollAtEnd(ctx) { } if (shouldMaintainScrollAtEnd) { state.pendingMaintainScrollAtEnd = false; @@ -652,7 +652,7 @@ index c2e0f38..5313086 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1518,9 +1535,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1570,9 +1587,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -674,7 +674,7 @@ index c2e0f38..5313086 100644 } setTimeout( () => { -@@ -1550,6 +1576,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -685,7 +685,7 @@ index c2e0f38..5313086 100644 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1653,7 +1683,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -696,7 +696,7 @@ index c2e0f38..5313086 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1715,7 +1747,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1769,7 +1801,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -705,7 +705,7 @@ index c2e0f38..5313086 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1848,7 +1880,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -714,7 +714,7 @@ index c2e0f38..5313086 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2253,8 +2285,121 @@ function scrollToIndex(ctx, { +@@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -836,7 +836,7 @@ index c2e0f38..5313086 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2683,7 +2828,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -847,9 +847,9 @@ index c2e0f38..5313086 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4616,7 +4763,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { - } - contentBelowAnchor += footerSize + stylePaddingBottom; +@@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); + contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); @@ -857,20 +857,20 @@ index c2e0f38..5313086 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4634,6 +4782,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateScroll(ctx, state.scroll, true); +@@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { + set$(ctx, "anchoredEndSpaceSize", nextSize); + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { -+ updateScroll(ctx, state.scroll, true); ++ updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); + } } return nextSize; } -@@ -6939,6 +7093,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7054,6 +7208,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -878,7 +878,7 @@ index c2e0f38..5313086 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7055,7 +7210,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7179,7 +7334,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -887,7 +887,7 @@ index c2e0f38..5313086 100644 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7194,6 +7349,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7320,6 +7475,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -895,7 +895,7 @@ index c2e0f38..5313086 100644 data: dataProp, dataKey, dataVersion, -@@ -7282,6 +7438,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7402,6 +7558,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -909,7 +909,7 @@ index c2e0f38..5313086 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7505,6 +7668,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7630,6 +7793,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -917,7 +917,7 @@ index c2e0f38..5313086 100644 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7534,6 +7698,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7660,6 +7824,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -926,7 +926,7 @@ index c2e0f38..5313086 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/reanimated.d.ts b/reanimated.d.ts -index 940da28..28dccbe 100644 +index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -294,6 +294,12 @@ interface LegendListSpecificProps { @@ -943,7 +943,7 @@ index 940da28..28dccbe 100644 * Number of columns to render items in. * @default 1 diff --git a/reanimated.js b/reanimated.js -index f1265fa..16dcef0 100644 +index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 100644 --- a/reanimated.js +++ b/reanimated.js @@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( @@ -978,7 +978,7 @@ index f1265fa..16dcef0 100644 style: viewStyle, ...rest diff --git a/reanimated.mjs b/reanimated.mjs -index 29a00d5..9c25ec5 100644 +index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 100644 --- a/reanimated.mjs +++ b/reanimated.mjs @@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index f03ef60bb5b..8794cf208ee 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -1,9 +1,117 @@ +diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm +index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644 +--- a/ios/NewArch/MenuView.mm ++++ b/ios/NewArch/MenuView.mm +@@ -105,6 +105,27 @@ - (void)onOpenMenu { + NSMutableArray *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()]; + if (action.subactions.size() > 0) { + for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) { ++ NSMutableArray *subSubactionsArray = ++ [NSMutableArray arrayWithCapacity:subaction.subactions.size()]; ++ for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) { ++ NSDictionary *subSubactionDict = @{ ++ @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()], ++ @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()], ++ @"titleColor": @(subSubaction.titleColor), ++ @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()], ++ @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()], ++ @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()], ++ @"imageColor": @(subSubaction.imageColor), ++ @"displayInline": @(subSubaction.displayInline), ++ @"attributes": @{ ++ @"destructive": @(subSubaction.attributes.destructive), ++ @"disabled": @(subSubaction.attributes.disabled), ++ @"hidden": @(subSubaction.attributes.hidden), ++ @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented), ++ }, ++ }; ++ [subSubactionsArray addObject:subSubactionDict]; ++ } + NSDictionary *subactionDict = @{ + @"id": [NSString stringWithUTF8String:subaction.id.c_str()], + @"title": [NSString stringWithUTF8String:subaction.title.c_str()], +@@ -118,7 +139,9 @@ - (void)onOpenMenu { + @"destructive": @(subaction.attributes.destructive), + @"disabled": @(subaction.attributes.disabled), + @"hidden": @(subaction.attributes.hidden), ++ @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented), + }, ++ @"subactions": subSubactionsArray, + }; + [subactionsArray addObject:subactionDict]; + } +@@ -138,6 +161,7 @@ - (void)onOpenMenu { + @"destructive": @(action.attributes.destructive), + @"disabled": @(action.attributes.disabled), + @"hidden": @(action.attributes.hidden), ++ @"keepsMenuPresented": @(action.attributes.keepsMenuPresented), + }, + @"subactions": subactionsArray, + }; diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift -@@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton { +@@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton { + self.setup() + } + ++ // Presentation is tracked from the two delegate methods the class already ++ // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping) ++ // shadows UIButton's own implementation and degrades the button-anchored ++ // presentation into generic context-menu chrome — an empty header row with ++ // a dismiss chevron appears above the actions. + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { ++ // Flush updates deferred by the presented-guard before the action ++ // provider snapshots self.menu (covers a stuck flag from an ++ // interaction that never ended cleanly). ++ if pendingMenu != nil { ++ pendingMenu = nil ++ isMenuPresented = false ++ self.setup() ++ } ++ isMenuPresented = true + sendMenuOpen() + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self = self else { return nil } + return self.menu + } + } +- ++ + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + sendMenuClose() ++ isMenuPresented = false ++ if pendingMenu != nil { ++ // Re-run the full assignment now that presentation is over, so ++ // any props deferred by the presented-guard (menu contents, press ++ // mode) land on the button. ++ pendingMenu = nil ++ self.setup() ++ } + } ++ private var isMenuPresented = false ++ private var pendingMenu: UIMenu? ++ + func setup () { + let menu = UIMenu(title: _title, + identifier: nil, +@@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton { + } + } + ++ if isMenuPresented { ++ // An action fired with keepsMenuPresented leaves the menu on ++ // screen, and reassigning self.menu while it is presented makes ++ // UIKit dismiss it — the update triggered by a selection would ++ // defeat the keep-presented attribute. Update the visible copy in ++ // place instead and defer the button assignment to dismissal. ++ pendingMenu = menu ++ self.refreshPresentedMenu(menu) ++ return ++ } ++ self.menu = menu self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress + // In long-press mode the button must not intercept touches: as a @@ -17,6 +125,50 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 + self.updateLongPressInteraction() + } + ++ private func refreshPresentedMenu(_ menu: UIMenu) { ++ // Tap mode presents through the button's built-in interaction, ++ // long-press mode through the superview-hosted one; cover both (plus ++ // the interaction added in init) and dedupe by identity. ++ var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction } ++ if let builtin = self.contextMenuInteraction { ++ candidates.append(builtin) ++ } ++ if let host = longPressInteraction { ++ candidates.append(host) ++ } ++ var visited: Set = [] ++ for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted { ++ // The block receives whichever menu level is currently on screen — ++ // the navigated submenu when the user picked inside one, not the ++ // root. Swap in the matching node from the rebuilt tree (stable ++ // identifiers from the JS action ids) so that level updates in ++ // place; returning an unrelated menu instead makes UIKit render it ++ // as navigation into a foreign menu, with a stale or blank ++ // expanded-submenu header row above the actions. The root carries ++ // an auto-generated identifier that never matches, so it falls ++ // through to a children-only replacement. ++ interaction.updateVisibleMenu { [weak self] visibleMenu in ++ guard let self = self else { return visibleMenu } ++ if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) { ++ return replacement ++ } ++ return visibleMenu.replacingChildren(menu.children) ++ } ++ } ++ } ++ ++ private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? { ++ if menu.identifier == identifier { ++ return menu ++ } ++ for element in menu.children { ++ if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) { ++ return match ++ } ++ } ++ return nil ++ } ++ + private var longPressInteraction: UIContextMenuInteraction? + + public override func didMoveToSuperview() { @@ -44,3 +196,74 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 } public override func reactSetFrame(_ frame: CGRect) { +diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift +index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644 +--- a/ios/Shared/RCTMenuItem.swift ++++ b/ios/Shared/RCTMenuItem.swift +@@ -103,10 +103,18 @@ class RCTMenuAction { + subMenuActions.append(subaction.createUIMenuElement(handler)) + } + var menu: UIMenu; ++ // Stable identifiers let updateVisibleMenu match submenu nodes in ++ // place, so a refresh while the menu is presented keeps the user's ++ // current submenu level instead of popping back to the root. ++ // Inline sections don't navigate, so they stay stock. ++ let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) } + if self.displayInline { + menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions) + } else { +- menu = UIMenu(title: title, image: image, children: subMenuActions) ++ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions) ++ } ++ if #available(iOS 15.0, *) { ++ menu.subtitle = subtitle + } + + if #available(iOS 16.0, *) { +diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts +index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644 +--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts ++++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts +@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati + types here, to avoid issues while `pod install` takes place. + */ + ++type SubSubAction = { ++ id?: string; ++ title: string; ++ titleColor?: Int32; ++ subtitle?: string; ++ state?: string; ++ image?: string; ++ imageColor?: Int32; ++ displayInline?: boolean; ++ attributes?: { ++ destructive?: boolean; ++ disabled?: boolean; ++ hidden?: boolean; ++ keepsMenuPresented?: boolean; ++ }; ++}; + type SubAction = { + id?: string; + title: string; +@@ -26,7 +42,11 @@ type SubAction = { + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; ++ keepsMenuPresented?: boolean; + }; ++ // One extra nesting level (menu → submenu → nested submenu leaves); the ++ // codegen structs can't recurse, so depth is capped explicitly. ++ subactions?: Array; + }; + type MenuAction = { + id?: string; +@@ -41,6 +61,7 @@ type MenuAction = { + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; ++ keepsMenuPresented?: boolean; + }; + subactions?: Array; + }; diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch index 7bd9fb744e9..605366ff19a 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.25.2.patch @@ -140,11 +140,24 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm -index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644 +index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb2199dcbd58 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm -@@ -30,6 +30,20 @@ +@@ -25,11 +25,33 @@ + #import "RNSSearchBar.h" + #import "UINavigationBar+RNSUtility.h" + ++#import ++ + namespace react = facebook::react; + static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; ++ ++// Keys for the last-applied JS bar button configs, associated with the ++// navigation item so unrelated header updates (title, subtitle, tint) don't ++// recreate the native buttons they configure. ++static char RNSAppliedHeaderBarButtonConfigsKey; ++static char RNSAppliedToolbarConfigsKey; static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; +static NSInteger navigationItemStyleFromCppEquivalent( @@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @interface RCTImageLoader (Private) - (id)imageCache; @end -@@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string +@@ -47,6 +69,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string @end @interface RNSScreenStackHeaderConfig () @@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @end @implementation RNSScreenStackHeaderConfig { -@@ -81,6 +98,7 @@ - (void)initProps +@@ -81,6 +106,7 @@ - (void)initProps self.hidden = YES; _reactSubviews = [NSMutableArray new]; _backTitleVisible = YES; @@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d _blurEffect = RNSBlurEffectStyleNone; } -@@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc +@@ -496,6 +522,10 @@ + (void)updateViewController:(UIViewController *)vc if (shouldHide) { navitem.title = config.title; @@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. [navctr setNavigationBarHidden:YES animated:animated]; -@@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc +@@ -512,11 +542,19 @@ + (void)updateViewController:(UIViewController *)vc } navitem.largeTitleDisplayMode = config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; @@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV -@@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc +@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -221,32 +234,62 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d - withCurrentItems:navitem.leftBarButtonItems]; - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems - withCurrentItems:navitem.rightBarButtonItems]; -+ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems -+ withCurrentItems:navitem.leftBarButtonItems -+ navigationItem:navitem]; -+ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems -+ withCurrentItems:navitem.rightBarButtonItems -+ navigationItem:navitem]; -+ NSArray *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems -+ withCurrentItems:@[] ++ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; ++ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; ++ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; ++ NSArray *subviewLeftItems = navitem.leftBarButtonItems ?: @[]; ++ NSArray *subviewRightItems = navitem.rightBarButtonItems ?: @[]; ++ // The key includes the config instance's identity: cached items capture ++ // this config's event emitter in their press handlers, so a remounted ++ // header-config view with value-equal configs must still rebuild — reusing ++ // the old items would dispatch presses into the dead config's emitter. ++ NSArray *headerItemsKey = ++ @[ @((uintptr_t)config), headerLeftConfigs, headerRightConfigs, headerCenterConfigs ]; ++ // Rebuilding bar button items creates brand-new native buttons (glass ++ // UIButton custom views on iOS 26). Replacing them while UIKit animates an ++ // existing one (menu capsule morph, push/pop glass transitions) strands the ++ // animation overlay — a stuck expanded capsule or an unmasked square back ++ // button. When the JS configs are unchanged, keep the already-applied items. ++ // Subview-backed items are re-derived every pass, so their presence forces ++ // the rebuild path. ++ BOOL reuseHeaderBarButtonItems = subviewLeftItems.count == 0 && subviewRightItems.count == 0 && ++ [objc_getAssociatedObject(navitem, &RNSAppliedHeaderBarButtonConfigsKey) isEqual:headerItemsKey]; ++ if (!reuseHeaderBarButtonItems) { ++ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems ++ withCurrentItems:navitem.leftBarButtonItems + navigationItem:navitem]; ++ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems ++ withCurrentItems:navitem.rightBarButtonItems ++ navigationItem:navitem]; ++ NSArray *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems ++ withCurrentItems:@[] ++ navigationItem:navitem]; +#if !TARGET_OS_TV -+ if (@available(iOS 16.0, *)) { -+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; -+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; -+ if (@available(iOS 26.0, *)) { -+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; ++ if (@available(iOS 16.0, *)) { ++ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; ++ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; ++ if (@available(iOS 26.0, *)) { ++ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; ++ } ++ navitem.leftBarButtonItems = nil; ++ navitem.rightBarButtonItems = nil; ++ } else { ++ navitem.leftBarButtonItems = leftBarButtonItems; ++ navitem.rightBarButtonItems = rightBarButtonItems; + } -+ navitem.leftBarButtonItems = nil; -+ navitem.rightBarButtonItems = nil; -+ } else { ++#else + navitem.leftBarButtonItems = leftBarButtonItems; + navitem.rightBarButtonItems = rightBarButtonItems; -+ } -+#else -+ navitem.leftBarButtonItems = leftBarButtonItems; -+ navitem.rightBarButtonItems = rightBarButtonItems; +#endif ++ // Only dict-driven items can be reused: with subview-backed items in the ++ // mix the applied state depends on view identity, so clear the key to ++ // force a rebuild on the next pass. ++ objc_setAssociatedObject( ++ navitem, ++ &RNSAppliedHeaderBarButtonConfigsKey, ++ subviewLeftItems.count == 0 && subviewRightItems.count == 0 ? headerItemsKey : nil, ++ OBJC_ASSOCIATION_RETAIN_NONATOMIC); ++ } + NSDictionary *mailSearchToolbarConfig = nil; + for (NSDictionary *toolbarConfig in config.headerToolbarItems) { + if (toolbarConfig[@"mailSearchToolbar"]) { @@ -269,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + if (existingMailSearchToolbar == nil) { + existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; + } -+ [existingMailSearchToolbar removeFromSuperview]; ++ // Reuse the live toolbar when nothing it was built from changed: rebuilding ++ // replaces the fallback UISearchTextField with a fresh empty one, dropping ++ // the user's in-progress search text and first responder on every unrelated ++ // header update. Keyed on the config instance (the button/search blocks ++ // capture its event emitter), the toolbar config values, and the host width ++ // (the width constraint constant is resolved from it at build time). ++ static char RNSAppliedMailSearchToolbarConfigKey; ++ NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil ++ ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ] ++ : nil; ++ BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil && ++ [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey) ++ isEqual:mailSearchToolbarKey]; ++ if (!reuseMailSearchToolbar) { ++ [existingMailSearchToolbar removeFromSuperview]; ++ } + -+ if (mailSearchToolbarConfig != nil) { ++ if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) { +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + if (@available(iOS 26.0, *)) { + CGFloat horizontalInset = 18.0; @@ -304,21 +362,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + + UIView *toolbarHost = [[UIView alloc] init]; + toolbarHost.tag = RNSMailSearchToolbarViewTag; ++ objc_setAssociatedObject( ++ toolbarHost, ++ &RNSAppliedMailSearchToolbarConfigKey, ++ mailSearchToolbarKey, ++ OBJC_ASSOCIATION_RETAIN_NONATOMIC); + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; + [chromeHostView addSubview:toolbarHost]; -+ // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard -+ // layout guide no longer rests at the bottom safe-area edge while the -+ // keyboard is hidden, which pushed the toolbar offscreen. The required -+ // resting position is the safe area; the keyboard guide only pulls the -+ // toolbar up when it actually tracks a visible keyboard. ++ // The screen stays mounted beneath pushed routes, so its keyboard layout ++ // guide can track a keyboard owned by another screen. Keep the toolbar at ++ // rest unless its own search field is editing. + NSLayoutConstraint *keyboardAvoidConstraint = + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor + constant:-toolbarBottomSpacing]; -+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; ++ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; + NSLayoutConstraint *restingBottomConstraint = + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor + constant:-toolbarBottomSpacing]; -+ restingBottomConstraint.priority = UILayoutPriorityDefaultLow; ++ restingBottomConstraint.priority = UILayoutPriorityDefaultHigh; + [NSLayoutConstraint activateConstraints:@[ + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor @@ -330,6 +391,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + ]]; + [chromeHostView bringSubviewToFront:toolbarHost]; + ++ void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) { ++ BOOL isEditing = textField.isFirstResponder; ++ keyboardAvoidConstraint.priority = ++ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; ++ restingBottomConstraint.priority = ++ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; ++ ++ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; ++ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; ++ __weak UIView *weakChromeHostView = chromeHostView; ++ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; ++ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; ++ [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin]; ++ [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd]; ++ [textField addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:beginActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow; ++ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; ++ [weakChromeHostView setNeedsLayout]; ++ }] ++ forControlEvents:UIControlEventEditingDidBegin]; ++ [textField addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:endActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; ++ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh; ++ [weakChromeHostView setNeedsLayout]; ++ }] ++ forControlEvents:UIControlEventEditingDidEnd]; ++ }; ++ + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; + glassEffect.interactive = YES; + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; @@ -411,6 +506,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; + searchBar.searchTextField.textColor = UIColor.labelColor; + searchBar.searchTextField.tintColor = UIColor.labelColor; ++ configureKeyboardTracking(searchBar.searchTextField); + if (placeholder != nil) { + searchBar.searchTextField.attributedPlaceholder = + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; @@ -443,6 +539,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + searchField.adjustsFontForContentSizeCategory = YES; + searchField.textColor = UIColor.labelColor; + searchField.tintColor = UIColor.labelColor; ++ configureKeyboardTracking(searchField); + searchField.translatesAutoresizingMaskIntoConstraints = NO; + [glassView.contentView addSubview:searchField]; + [NSLayoutConstraint activateConstraints:@[ @@ -491,20 +588,34 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + navigationToolbarConfigs = @[]; + } + -+ NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs -+ withCurrentItems:@[] -+ navigationItem:navitem]; -+ if (toolbarItems.count > 0) { -+ vc.toolbarItems = toolbarItems; -+ [navctr setToolbarHidden:NO animated:animated]; -+ } else { -+ vc.toolbarItems = nil; -+ [navctr setToolbarHidden:YES animated:animated]; ++ NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[]; ++ // Same reuse rule as the header item groups above (including the config ++ // identity — cached toolbar items capture this config's event emitter). ++ // The top-view-controller and hidden-state checks scope the skip to ++ // same-screen refreshes, so transitions between screens with different ++ // toolbars still reapply. ++ NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ]; ++ BOOL reuseToolbarItems = navctr.topViewController == vc && ++ navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) && ++ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey]; ++ if (!reuseToolbarItems) { ++ NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs ++ withCurrentItems:@[] ++ navigationItem:navitem]; ++ if (toolbarItems.count > 0) { ++ vc.toolbarItems = toolbarItems; ++ [navctr setToolbarHidden:NO animated:animated]; ++ } else { ++ vc.toolbarItems = nil; ++ [navctr setToolbarHidden:YES animated:animated]; ++ } ++ objc_setAssociatedObject( ++ navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). -@@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems @@ -512,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d { if (dicts.count == 0) { return currentItems; -@@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObjectsFromArray:currentItems]; for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; @@ -700,7 +811,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + } + } +#endif -+ if (index != nil && index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { + [items insertObject:item atIndex:index.integerValue]; + } else { + [items addObject:item]; @@ -711,7 +822,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( -@@ -809,11 +1306,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + } + imageLoader:_imageLoader]; + NSNumber *index = dict[@"index"]; +- if (index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { + [items insertObject:item atIndex:index.integerValue]; + } else { [items addObject:item]; } } else if (dict[@"spacing"]) { @@ -729,9 +847,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + item.width = [spacingValue doubleValue]; + } NSNumber *index = dict[@"index"]; - if (index.integerValue < items.count) { +- if (index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { [items insertObject:item atIndex:index.integerValue]; -@@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + } else { + [items addObject:item]; +@@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } @@ -779,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d RNS_IGNORE_SUPER_CALL_BEGIN - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex { -@@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); @@ -788,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); } -@@ -1038,6 +1582,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; @@ -796,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; -@@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b999993183..8796302bab4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@effect/tsgo': specifier: 0.13.2 version: 0.13.2 + '@legendapp/list': + specifier: 3.3.5 + version: 3.3.5 '@noble/curves': specifier: 1.9.1 version: 1.9.1 @@ -69,12 +72,13 @@ overrides: packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: + '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 - '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 + '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa - '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae + '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 @@ -82,7 +86,7 @@ patchedDependencies: react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 + react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e importers: @@ -197,7 +201,7 @@ importers: version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: 4.2.0 - version: 4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) @@ -211,8 +215,8 @@ importers: specifier: ~56.0.18 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) '@legendapp/list': - specifier: 3.3.3 - version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 'catalog:' + version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -224,7 +228,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) @@ -233,7 +237,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -398,7 +402,7 @@ importers: version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -537,8 +541,8 @@ importers: specifier: ^0.9.0 version: 0.9.0 '@legendapp/list': - specifier: 3.3.3 - version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 'catalog:' + version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -2950,8 +2954,8 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} - '@legendapp/list@3.3.3': - resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} + '@legendapp/list@3.3.5': + resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==} peerDependencies: react: '*' react-dom: '*' @@ -11584,7 +11588,7 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': + '@clerk/expo@4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': dependencies: '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -12253,7 +12257,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) + expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12329,7 +12333,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) + expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12669,7 +12673,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) + expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12684,7 +12688,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) + expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -12987,7 +12991,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12995,7 +12999,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) @@ -14091,7 +14095,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -14269,7 +14273,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14277,7 +14281,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -17056,7 +17060,7 @@ snapshots: - supports-color - typescript - expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7): + expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8): dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -17087,7 +17091,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -17107,7 +17111,7 @@ snapshots: - supports-color optional: true - expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f): + expo-router@56.2.11(db5c693a26481047569df6781f34db9f): dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) @@ -17138,7 +17142,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -19953,14 +19957,14 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: react: 19.2.6 react-freeze: 1.0.4(react@19.2.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 27d86fd1784..f6b90756046 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,6 +39,7 @@ catalog: "@effect/sql-sqlite-bun": 4.0.0-beta.103 "@effect/tsgo": 0.13.2 "@effect/vitest": 4.0.0-beta.103 + "@legendapp/list": 3.3.5 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 "@pierre/diffs": 1.3.0-beta.10 @@ -74,6 +75,7 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-beta.103" - alchemy@2.0.0-beta.65 - effect@4.0.0-beta.103 + - "@legendapp/list@3.3.5" overrides: "@clerk/backend": "catalog:" @@ -122,10 +124,11 @@ packageExtensions: vite: "catalog:" patchedDependencies: + "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch - "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch + "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch