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