authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-11 16:05:03+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 14:05:03+00:00
log6676f9c83b6a6dd1e2076eaa68dff48845ad955b
tree7c5a24a445475243efa2068f382709e84a5c80c3
parent65b005f1e4bfccb6a404b3b1e5bfa363d534ac2a
signature Signed by PGP key B5690EEEBB952194

fix(mobile): stabilize thread composer and interactions (#5986)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Thuong Tin <thuongtin@gmail.com> Co-authored-by: Kapish14 <kapishnarang01@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

38 files changed, 3411 insertions(+), 1421 deletions(-)

apps/mobile/package.json+1-1
...@@ -49,7 +49,7 @@...@@ -49,7 +49,7 @@
49 "@expo-google-fonts/dm-sans": "^0.4.2",49 "@expo-google-fonts/dm-sans": "^0.4.2",
50 "@expo/metro-runtime": "~56.0.15",50 "@expo/metro-runtime": "~56.0.15",
51 "@expo/ui": "~56.0.18",51 "@expo/ui": "~56.0.18",
52 "@legendapp/list": "3.3.3",52 "@legendapp/list": "catalog:",
53 "@noble/curves": "catalog:",53 "@noble/curves": "catalog:",
54 "@noble/hashes": "catalog:",54 "@noble/hashes": "catalog:",
55 "@pierre/diffs": "catalog:",55 "@pierre/diffs": "catalog:",
apps/mobile/src/Stack.tsx+20-13
...@@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText";...@@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText";
18import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";18import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
19import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";19import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
20import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";20import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
21import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
22import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";21import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
23import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";22import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
24import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";23import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
...@@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {...@@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
134 presentation: "fullScreenModal",133 presentation: "fullScreenModal",
135};134};
136135
137const SettingsSheetStack = createNativeStackNavigator({136const SettingsContentStack = createNativeStackNavigator({
138 initialRouteName: "Settings",137 initialRouteName: "Settings",
139 screenOptions: {138 screenOptions: {
140 ...GLASS_HEADER_OPTIONS,139 ...GLASS_HEADER_OPTIONS,
...@@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({...@@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({
198 title: "Usage",197 title: "Usage",
199 },198 },
200 }),199 }),
200 },
201});
202
203// The outer stack never owns visible chrome. Settings routes render inside a
204// nested stack whose native header remains mounted, while Clerk owns auth chrome.
205// Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump.
206const SettingsSheetStack = createNativeStackNavigator({
207 initialRouteName: "SettingsContent",
208 screenOptions: {
209 headerShown: false,
210 },
211 screens: {
212 SettingsContent: createNativeStackScreen({
213 screen: SettingsContentStack,
214 linking: "",
215 }),
201 SettingsAuth: createNativeStackScreen({216 SettingsAuth: createNativeStackScreen({
202 screen: SettingsAuthRouteScreen,217 screen: SettingsAuthRouteScreen,
203 linking: "auth",218 linking: "auth",
204 options: {
205 title: "Sign in",
206 },
207 }),219 }),
208 SettingsWaitlist: createNativeStackScreen({220 SettingsWaitlist: createNativeStackScreen({
209 // Keep the old deep link working after the Connect GA launch.221 // Keep the old deep link working after the Connect GA launch.
210 screen: SettingsAuthRouteScreen,222 screen: SettingsAuthRouteScreen,
211 linking: "waitlist",223 linking: "waitlist",
212 options: {
213 title: "Sign in",
214 },
215 }),224 }),
216 },225 },
217});226});
...@@ -347,11 +356,9 @@ function RootStackLayout(props: {...@@ -347,11 +356,9 @@ function RootStackLayout(props: {
347 <HardwareKeyboardCommandProvider pathname={pathname}>356 <HardwareKeyboardCommandProvider pathname={pathname}>
348 <ThreadOutboxDrainWorker />357 <ThreadOutboxDrainWorker />
349 <ShowcaseCaptureCoordinator pathname={pathname} />358 <ShowcaseCaptureCoordinator pathname={pathname} />
350 <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>359 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
351 <AdaptiveWorkspaceLayout pathname={workspacePathname}>360 {props.children}
352 {props.children}361 </AdaptiveWorkspaceLayout>
353 </AdaptiveWorkspaceLayout>
354 </ClerkSettingsSheetDetentProvider>
355 </HardwareKeyboardCommandProvider>362 </HardwareKeyboardCommandProvider>
356 );363 );
357}364}
apps/mobile/src/components/ControlPill.tsx+26-1
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 type ComponentProps,6 type ComponentProps,
7 type ReactElement,7 type ReactElement,
8 type ReactNode,8 type ReactNode,
9 useRef,
9} from "react";10} from "react";
10import { Platform, Pressable, useColorScheme, View } from "react-native";11import { Platform, Pressable, useColorScheme, View } from "react-native";
11import { useThemeColor } from "../lib/useThemeColor";12import { useThemeColor } from "../lib/useThemeColor";
...@@ -21,10 +22,31 @@ export function ControlPill(props: {...@@ -21,10 +22,31 @@ export function ControlPill(props: {
21 readonly label?: string;22 readonly label?: string;
22 readonly accessibilityLabel?: string;23 readonly accessibilityLabel?: string;
23 readonly onPress?: () => void;24 readonly onPress?: () => void;
25 readonly activateOnPressIn?: boolean;
24 readonly variant?: "circle" | "pill" | "primary" | "danger";26 readonly variant?: "circle" | "pill" | "primary" | "danger";
25 readonly disabled?: boolean;27 readonly disabled?: boolean;
28 readonly className?: string;
26}) {29}) {
27 const variant = props.variant ?? "circle";30 const variant = props.variant ?? "circle";
31 const activatedOnPressInRef = useRef(false);
32
33 const handlePressIn = () => {
34 activatedOnPressInRef.current = true;
35 props.onPress?.();
36 };
37 const handlePressOut = () => {
38 // Pressability invokes onPressOut immediately before onPress on release.
39 // Defer the reset so onPress can identify the same physical gesture.
40 setTimeout(() => {
41 activatedOnPressInRef.current = false;
42 }, 0);
43 };
44 const handlePress = () => {
45 if (activatedOnPressInRef.current) {
46 return;
47 }
48 props.onPress?.();
49 };
2850
29 const iconColor = useThemeColor("--color-icon");51 const iconColor = useThemeColor("--color-icon");
30 const iconSubtle = useThemeColor("--color-icon-subtle");52 const iconSubtle = useThemeColor("--color-icon-subtle");
...@@ -54,6 +76,7 @@ export function ControlPill(props: {...@@ -54,6 +76,7 @@ export function ControlPill(props: {
54 : variant === "danger"76 : variant === "danger"
55 ? "bg-danger"77 ? "bg-danger"
56 : "bg-subtle",78 : "bg-subtle",
79 props.className,
57 );80 );
58 const labelClassName = cn(81 const labelClassName = cn(
59 "text-center text-xs font-t3-bold",82 "text-center text-xs font-t3-bold",
...@@ -68,7 +91,9 @@ export function ControlPill(props: {...@@ -68,7 +91,9 @@ export function ControlPill(props: {
68 <Pressable91 <Pressable
69 accessibilityLabel={props.accessibilityLabel ?? props.label}92 accessibilityLabel={props.accessibilityLabel ?? props.label}
70 accessibilityRole="button"93 accessibilityRole="button"
71 onPress={props.onPress}94 onPress={props.activateOnPressIn ? handlePress : props.onPress}
95 onPressIn={props.activateOnPressIn ? handlePressIn : undefined}
96 onPressOut={props.activateOnPressIn ? handlePressOut : undefined}
72 disabled={props.disabled}97 disabled={props.disabled}
73 className={containerClassName}98 className={containerClassName}
74 >99 >
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+1-4
...@@ -5,7 +5,6 @@ import { useFocusEffect } from "@react-navigation/native";...@@ -5,7 +5,6 @@ import { useFocusEffect } from "@react-navigation/native";
5import { useCallback, useMemo, useState } from "react";5import { useCallback, useMemo, useState } from "react";
66
7import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";7import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
8import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
9import { useArchivedThreadListActions } from "../home/useThreadListActions";8import { useArchivedThreadListActions } from "../home/useThreadListActions";
10import {9import {
11 ArchivedThreadsScreen,10 ArchivedThreadsScreen,
...@@ -18,7 +17,6 @@ import {...@@ -18,7 +17,6 @@ import {
18} from "./useArchivedThreadSnapshots";17} from "./useArchivedThreadSnapshots";
1918
20export function ArchivedThreadsRouteScreen() {19export function ArchivedThreadsRouteScreen() {
21 const { expand } = useClerkSettingsSheetDetent();
22 const { savedConnectionsById } = useSavedRemoteConnections();20 const { savedConnectionsById } = useSavedRemoteConnections();
23 const [searchQuery, setSearchQuery] = useState("");21 const [searchQuery, setSearchQuery] = useState("");
24 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);22 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
...@@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() {...@@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() {
7068
71 useFocusEffect(69 useFocusEffect(
72 useCallback(() => {70 useCallback(() => {
73 expand();
74 refresh();71 refresh();
75 }, [expand, refresh]),72 }, [refresh]),
76 );73 );
7774
78 return (75 return (
apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx deleted-44
...@@ -1,44 +0,0 @@
1import {
2 createContext,
3 type PropsWithChildren,
4 useCallback,
5 useContext,
6 useMemo,
7 useState,
8} from "react";
9
10interface ClerkSettingsSheetDetentValue {
11 collapse: () => void;
12 expand: () => void;
13 isExpanded: boolean;
14}
15
16const ClerkSettingsSheetDetentContext = createContext<ClerkSettingsSheetDetentValue | null>(null);
17
18interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren {
19 initiallyExpanded: boolean;
20}
21
22export function ClerkSettingsSheetDetentProvider({
23 children,
24 initiallyExpanded,
25}: ClerkSettingsSheetDetentProviderProps) {
26 const [isExpanded, setIsExpanded] = useState(initiallyExpanded);
27 const collapse = useCallback(() => setIsExpanded(false), []);
28 const expand = useCallback(() => setIsExpanded(true), []);
29 const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]);
30
31 return (
32 <ClerkSettingsSheetDetentContext value={value}>{children}</ClerkSettingsSheetDetentContext>
33 );
34}
35
36export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue {
37 const value = useContext(ClerkSettingsSheetDetentContext);
38 if (!value) {
39 throw new Error(
40 "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider",
41 );
42 }
43 return value;
44}
apps/mobile/src/features/cloud/connectOnboardingNavigation.ts+2-2
...@@ -6,8 +6,8 @@ import { appAtomRegistry } from "../../state/atom-registry";...@@ -6,8 +6,8 @@ import { appAtomRegistry } from "../../state/atom-registry";
6import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";6import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";
7import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut";7import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut";
88
9// Sign-in happens inside the Settings sheet; give its detent/session-state9// Sign-in happens inside the Settings sheet; give its session-state transition
10// transitions a beat to settle before presenting another formSheet on top.10// a beat to settle before presenting another formSheet on top.
11const PRESENT_ONBOARDING_DELAY_MS = 600;11const PRESENT_ONBOARDING_DELAY_MS = 600;
1212
13/**13/**
apps/mobile/src/features/home/HomeRouteScreen.tsx+24-5
...@@ -134,7 +134,10 @@ export function HomeRouteScreen() {...@@ -134,7 +134,10 @@ export function HomeRouteScreen() {
134 <NativeStackScreenOptions134 <NativeStackScreenOptions
135 options={getConnectionAwareBrandHeaderOptions({135 options={getConnectionAwareBrandHeaderOptions({
136 onOpenEnvironments: () =>136 onOpenEnvironments: () =>
137 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }),137 navigation.navigate("SettingsSheet", {
138 screen: "SettingsContent",
139 params: { screen: "SettingsEnvironments" },
140 }),
138 })}141 })}
139 />142 />
140 <HomeHeader143 <HomeHeader
...@@ -148,9 +151,17 @@ export function HomeRouteScreen() {...@@ -148,9 +151,17 @@ export function HomeRouteScreen() {
148 onEnvironmentChange={setSelectedEnvironmentId}151 onEnvironmentChange={setSelectedEnvironmentId}
149 onProjectChange={setSelectedProjectKey}152 onProjectChange={setSelectedProjectKey}
150 onOpenEnvironments={() =>153 onOpenEnvironments={() =>
151 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" })154 navigation.navigate("SettingsSheet", {
155 screen: "SettingsContent",
156 params: { screen: "SettingsEnvironments" },
157 })
158 }
159 onOpenSettings={() =>
160 navigation.navigate("SettingsSheet", {
161 screen: "SettingsContent",
162 params: { screen: "Settings" },
163 })
152 }164 }
153 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
154 onProjectSortOrderChange={setProjectSortOrder}165 onProjectSortOrderChange={setProjectSortOrder}
155 onSearchQueryChange={setSearchQuery}166 onSearchQueryChange={setSearchQuery}
156 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}167 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
...@@ -161,7 +172,10 @@ export function HomeRouteScreen() {...@@ -161,7 +172,10 @@ export function HomeRouteScreen() {
161 catalogState={catalogState}172 catalogState={catalogState}
162 environments={environments}173 environments={environments}
163 onAddConnection={() =>174 onAddConnection={() =>
164 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })175 navigation.navigate("SettingsSheet", {
176 screen: "SettingsContent",
177 params: { screen: "SettingsEnvironmentNew" },
178 })
165 }179 }
166 onArchiveThread={archiveThread}180 onArchiveThread={archiveThread}
167 onDeleteThread={confirmDeleteThread}181 onDeleteThread={confirmDeleteThread}
...@@ -174,7 +188,12 @@ export function HomeRouteScreen() {...@@ -174,7 +188,12 @@ export function HomeRouteScreen() {
174 onMovePinnedThread={movePinnedThread}188 onMovePinnedThread={movePinnedThread}
175 onEnvironmentChange={setSelectedEnvironmentId}189 onEnvironmentChange={setSelectedEnvironmentId}
176 onProjectChange={setSelectedProjectKey}190 onProjectChange={setSelectedProjectKey}
177 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}191 onOpenSettings={() =>
192 navigation.navigate("SettingsSheet", {
193 screen: "SettingsContent",
194 params: { screen: "Settings" },
195 })
196 }
178 onProjectSortOrderChange={setProjectSortOrder}197 onProjectSortOrderChange={setProjectSortOrder}
179 onSearchQueryChange={setSearchQuery}198 onSearchQueryChange={setSearchQuery}
180 onSelectThread={(thread) => {199 onSelectThread={(thread) => {
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+8-2
...@@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent(...@@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent(
429 );429 );
430430
431 const handleOpenSettings = useCallback(() => {431 const handleOpenSettings = useCallback(() => {
432 navigation.navigate("SettingsSheet", { screen: "Settings" });432 navigation.navigate("SettingsSheet", {
433 screen: "SettingsContent",
434 params: { screen: "Settings" },
435 });
433 }, [navigation]);436 }, [navigation]);
434437
435 // Minted here (root stack navigation) so the sidebar pane stays free of438 // Minted here (root stack navigation) so the sidebar pane stays free of
436 // navigation hooks — on iOS it renders inside an independent nav tree.439 // navigation hooks — on iOS it renders inside an independent nav tree.
437 const handleOpenEnvironmentSettings = useCallback(() => {440 const handleOpenEnvironmentSettings = useCallback(() => {
438 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" });441 navigation.navigate("SettingsSheet", {
442 screen: "SettingsContent",
443 params: { screen: "SettingsEnvironments" },
444 });
439 }, [navigation]);445 }, [navigation]);
440446
441 const handleNewThreadInProject = useCallback(447 const handleNewThreadInProject = useCallback(
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx+16-17
...@@ -1,8 +1,7 @@...@@ -1,8 +1,7 @@
1import { useAuth } from "@clerk/expo";1import { useAuth } from "@clerk/expo";
2import { AuthView, UserProfileView } from "@clerk/expo/native";2import { AuthView, UserProfileView } from "@clerk/expo/native";
3import { StackActions, useNavigation } from "@react-navigation/native";3import { StackActions, useNavigation } from "@react-navigation/native";
4import { NativeStackScreenOptions } from "../../native/StackHeader";4import { useCallback, useLayoutEffect } from "react";
5import { useCallback, useEffect } from "react";
6import { View } from "react-native";5import { View } from "react-native";
76
8import { hasCloudPublicConfig } from "../cloud/publicConfig";7import { hasCloudPublicConfig } from "../cloud/publicConfig";
...@@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig";...@@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig";
10export function SettingsAuthRouteScreen() {9export function SettingsAuthRouteScreen() {
11 const navigation = useNavigation();10 const navigation = useNavigation();
1211
13 useEffect(() => {12 useLayoutEffect(() => {
14 if (!hasCloudPublicConfig()) {13 if (!hasCloudPublicConfig()) {
15 navigation.dispatch(StackActions.replace("Settings"));14 navigation.dispatch(StackActions.replace("SettingsContent"));
16 }15 }
17 }, [navigation]);16 }, [navigation]);
1817
...@@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() {...@@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() {
22function ConfiguredSettingsAuthRouteScreen() {21function ConfiguredSettingsAuthRouteScreen() {
23 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });22 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
24 const navigation = useNavigation();23 const navigation = useNavigation();
25 const handleHostBack = useCallback(() => navigation.goBack(), [navigation]);24 const handleHostBack = useCallback(
25 () => navigation.dispatch(StackActions.popTo("SettingsContent")),
26 [navigation],
27 );
2628
27 return (29 return (
28 <>30 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
29 <NativeStackScreenOptions options={{ headerShown: false }} />31 {isLoaded ? (
30 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">32 isSignedIn ? (
31 {isLoaded ? (33 <UserProfileView isDismissible={false} onHostBack={handleHostBack} />
32 isSignedIn ? (34 ) : (
33 <UserProfileView isDismissible={false} onHostBack={handleHostBack} />35 <AuthView isDismissible={false} onHostBack={handleHostBack} />
34 ) : (36 )
35 <AuthView isDismissible={false} onHostBack={handleHostBack} />37 ) : null}
36 )38 </View>
37 ) : null}
38 </View>
39 </>
40 );39 );
41}40}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+8-2
...@@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() {
98 accessibilityLabel: "Add environment",98 accessibilityLabel: "Add environment",
99 icon: "plus",99 icon: "plus",
100 onPress: () =>100 onPress: () =>
101 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }),101 navigation.navigate("SettingsSheet", {
102 screen: "SettingsContent",
103 params: { screen: "SettingsEnvironmentNew" },
104 }),
102 },105 },
103 ]}106 ]}
104 />107 />
...@@ -108,7 +111,10 @@ export function SettingsEnvironmentsRouteScreen() {...@@ -108,7 +111,10 @@ export function SettingsEnvironmentsRouteScreen() {
108 <NativeHeaderToolbar.Button111 <NativeHeaderToolbar.Button
109 icon="plus"112 icon="plus"
110 onPress={() =>113 onPress={() =>
111 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })114 navigation.navigate("SettingsSheet", {
115 screen: "SettingsContent",
116 params: { screen: "SettingsEnvironmentNew" },
117 })
112 }118 }
113 separateBackground119 separateBackground
114 tintColor={headerIconColor}120 tintColor={headerIconColor}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+6-13
...@@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo";...@@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";2import { useAtomSet, useAtomValue } from "@effect/atom-react";
3import Constants from "expo-constants";3import Constants from "expo-constants";
4import * as Notifications from "expo-notifications";4import * as Notifications from "expo-notifications";
5import * as Updates from "expo-updates";
6import { useNavigation } from "@react-navigation/native";5import { useNavigation } from "@react-navigation/native";
7import { NativeStackScreenOptions } from "../../native/StackHeader";6import { NativeStackScreenOptions } from "../../native/StackHeader";
8import { SymbolView } from "../../components/AppSymbol";7import { SymbolView } from "../../components/AppSymbol";
...@@ -30,7 +29,6 @@ import {...@@ -30,7 +29,6 @@ import {
30 subscribeAgentAwarenessRegistrationStatus,29 subscribeAgentAwarenessRegistrationStatus,
31} from "../agent-awareness/remoteRegistration";30} from "../agent-awareness/remoteRegistration";
32import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";31import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
33import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
34import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";32import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
35import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";33import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
36import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";34import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
...@@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/...@@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/
40import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";38import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
41import {39import {
42 type AppUpdateCheckState,40 type AppUpdateCheckState,
41 isAppUpdateCheckAvailable,
43 registerHiddenUpdateTap,42 registerHiddenUpdateTap,
44 runAppUpdateCheck,43 runAppUpdateCheck,
45} from "../updates/app-updates";44} from "../updates/app-updates";
...@@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() {...@@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() {
147 const agentAwarenessPushAvailable = supportsAgentAwarenessPush();146 const agentAwarenessPushAvailable = supportsAgentAwarenessPush();
148 const insets = useSafeAreaInsets();147 const insets = useSafeAreaInsets();
149 const navigation = useNavigation();148 const navigation = useNavigation();
150 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
151 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });149 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
152 const { user } = useUser();150 const { user } = useUser();
153 const { savedConnectionsById } = useSavedRemoteConnections();151 const { savedConnectionsById } = useSavedRemoteConnections();
...@@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() {...@@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() {
436434
437 const openAccount = useCallback(() => {435 const openAccount = useCallback(() => {
438 if (!isLoaded) return;436 if (!isLoaded) return;
439 if (!isSignedIn) {
440 expandClerkSheet();
441 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
442 return;
443 }
444 expandClerkSheet();
445 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });437 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
446 }, [expandClerkSheet, isLoaded, isSignedIn, navigation]);438 }, [isLoaded, navigation]);
447439
448 return (440 return (
449 <View collapsable={false} className="flex-1 bg-sheet">441 <View collapsable={false} className="flex-1 bg-sheet">
...@@ -577,6 +569,7 @@ function AppSettingsSection() {...@@ -577,6 +569,7 @@ function AppSettingsSection() {
577 const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production";569 const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production";
578 const variantLabel = variant === "production" ? "" : capitalize(variant);570 const variantLabel = variant === "production" ? "" : capitalize(variant);
579 const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version;571 const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version;
572 const updateCheckAvailable = isAppUpdateCheckAvailable();
580 const busy =573 const busy =
581 updateState === "checking" || updateState === "downloading" || updateState === "restarting";574 updateState === "checking" || updateState === "downloading" || updateState === "restarting";
582575
...@@ -604,13 +597,13 @@ function AppSettingsSection() {...@@ -604,13 +597,13 @@ function AppSettingsSection() {
604 }, []);597 }, []);
605598
606 const handleVersionPress = useCallback(() => {599 const handleVersionPress = useCallback(() => {
607 if (!Updates.isEnabled || updateInFlight.current) return;600 if (!updateCheckAvailable || updateInFlight.current) return;
608 const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current);601 const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current);
609 hiddenUpdateTapCount.current = tap.nextCount;602 hiddenUpdateTapCount.current = tap.nextCount;
610 if (tap.shouldCheck) {603 if (tap.shouldCheck) {
611 void checkForUpdate();604 void checkForUpdate();
612 }605 }
613 }, [checkForUpdate]);606 }, [checkForUpdate, updateCheckAvailable]);
614607
615 const statusLabel =608 const statusLabel =
616 updateState === "checking"609 updateState === "checking"
...@@ -646,7 +639,7 @@ function AppSettingsSection() {...@@ -646,7 +639,7 @@ function AppSettingsSection() {
646 <SettingsSection title="App">639 <SettingsSection title="App">
647 <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />640 <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
648 <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />641 <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />
649 {Updates.isEnabled ? (642 {updateCheckAvailable ? (
650 <Pressable643 <Pressable
651 accessibilityLabel={`Version ${versionLabel}`}644 accessibilityLabel={`Version ${versionLabel}`}
652 accessibilityRole="text"645 accessibilityRole="text"
apps/mobile/src/features/settings/components/SettingsRow.tsx+2-1
...@@ -64,7 +64,8 @@ export function SettingsRow(props: {...@@ -64,7 +64,8 @@ export function SettingsRow(props: {
64 disabled={props.disabled}64 disabled={props.disabled}
65 onPress={() =>65 onPress={() =>
66 navigation.navigate("SettingsSheet", {66 navigation.navigate("SettingsSheet", {
67 screen: target,67 screen: "SettingsContent",
68 params: { screen: target },
68 })69 })
69 }70 }
70 >71 >
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx+20-8
...@@ -1,6 +1,12 @@...@@ -1,6 +1,12 @@
1import { useEffect, useRef, useState } from "react";1import { useEffect, useRef, useState } from "react";
2import { Keyboard, View } from "react-native";2import { Keyboard, View } from "react-native";
3import { CommonActions, StackActions, useNavigation } from "@react-navigation/native";3import {
4 CommonActions,
5 type NavigationState,
6 type PartialState,
7 StackActions,
8 useNavigation,
9} from "@react-navigation/native";
4import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
511
6import { useConnectionController } from "../connection/useConnectionController";12import { useConnectionController } from "../connection/useConnectionController";
...@@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry";...@@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry";
25const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";31const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
26const SHOWCASE_THREAD_ID = "remote-command-center";32const SHOWCASE_THREAD_ID = "remote-command-center";
2733
34type ShowcaseResetRoute = PartialState<NavigationState>["routes"][number];
35
28function sceneFromPathname(pathname: string): ShowcaseScene | null {36function sceneFromPathname(pathname: string): ShowcaseScene | null {
29 const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname;37 const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname;
30 if (routePath === "/settings" || routePath.endsWith("/settings/environments")) {38 if (routePath === "/settings" || routePath.endsWith("/settings/environments")) {
...@@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })...@@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
166 navigation.dispatch(StackActions.popToTop());174 navigation.dispatch(StackActions.popToTop());
167 return;175 return;
168 }176 }
169 const routes: Array<{177 const routes: ShowcaseResetRoute[] = [{ name: "Home" }];
170 name: string;
171 params?: Record<string, unknown>;
172 state?: { index: number; routes: Array<{ name: string }> };
173 }> = [{ name: "Home" }];
174 if (requestedScene === "environments") {178 if (requestedScene === "environments") {
175 routes.push({179 routes.push({
176 name: "SettingsSheet",180 name: "SettingsSheet",
177 state: {181 state: {
178 index: 1,182 index: 0,
179 routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }],183 routes: [
184 {
185 name: "SettingsContent",
186 state: {
187 index: 1,
188 routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }],
189 },
190 },
191 ],
180 },192 },
181 });193 });
182 } else {194 } else {
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+23-4
...@@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
2import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";2import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
3import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";4import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native";
5import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller";5import {
6 KeyboardAvoidingView,
7 KeyboardStickyView,
8 useKeyboardState,
9} from "react-native-keyboard-controller";
6import { useSafeAreaInsets } from "react-native-safe-area-context";10import { useSafeAreaInsets } from "react-native-safe-area-context";
7import { useThemeColor } from "../../lib/useThemeColor";11import { useThemeColor } from "../../lib/useThemeColor";
8import { useFontFamily } from "../../lib/useFontFamily";12import { useFontFamily } from "../../lib/useFontFamily";
...@@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: {...@@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: {
985 // The draft is a thread that doesn't exist yet, so it mirrors the thread989 // The draft is a thread that doesn't exist yet, so it mirrors the thread
986 // page: in-screen header, empty feed canvas above, and the same floating990 // page: in-screen header, empty feed canvas above, and the same floating
987 // composer chrome as ThreadComposer (collapsed pill → expanded card).991 // composer chrome as ThreadComposer (collapsed pill → expanded card).
992 //
993 // Composer positioning mirrors ThreadDetailScreen's floating overlay
994 // (KeyboardStickyView, absolute bottom overlay) rather than
995 // KeyboardAvoidingView's automaticOffset+padding: automaticOffset
996 // resolves the composer's on-screen frame via a native
997 // viewPositionInWindow measurement, which this app's Android
998 // edge-to-edge setup (KeyboardProvider's native content-view margin
999 // handling neutralizes windowSoftInputMode="adjustResize" while active)
1000 // makes unreliable — the composer stayed under the keyboard instead of
1001 // translating above it. KeyboardStickyView sticks directly to the
1002 // animated keyboard height instead, sidestepping that measurement.
988 return (1003 return (
989 <View className="flex-1 bg-screen">1004 <View className="flex-1 bg-screen">
990 <NativeStackScreenOptions options={{ headerShown: false }} />1005 <NativeStackScreenOptions options={{ headerShown: false }} />
991 <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} />1006 <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} />
9921007
993 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">1008 <View className="flex-1" />
994 <View className="flex-1" />
9951009
1010 <KeyboardStickyView
1011 enabled={isKeyboardVisible}
1012 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
1013 offset={{ closed: 0, opened: 0 }}
1014 >
996 <View1015 <View
997 className="px-4 pt-2"1016 className="px-4 pt-2"
998 style={{1017 style={{
...@@ -1056,7 +1075,7 @@ export function NewTaskDraftScreen(props: {...@@ -1056,7 +1075,7 @@ export function NewTaskDraftScreen(props: {
1056 </ComposerToolbarRow>1075 </ComposerToolbarRow>
1057 ) : null}1076 ) : null}
1058 </View>1077 </View>
1059 </KeyboardAvoidingView>1078 </KeyboardStickyView>
1060 {settingsSheet}1079 {settingsSheet}
1061 </View>1080 </View>
1062 );1081 );
apps/mobile/src/features/threads/PendingUserInputCard.tsx+301-67
...@@ -1,18 +1,64 @@...@@ -1,18 +1,64 @@
1import type { ApprovalRequestId } from "@t3tools/contracts";1import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts";
2import { Pressable, View } from "react-native";2import { useCallback, useRef } from "react";
3import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
4import Animated, {
5 Easing,
6 FadeInUp,
7 FadeOutDown,
8 LinearTransition,
9 useAnimatedStyle,
10 useSharedValue,
11 withTiming,
12 type SharedValue,
13} from "react-native-reanimated";
314
15import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout";
16
17import { SymbolView } from "../../components/AppSymbol";
4import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";18import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
19import { ControlPill } from "../../components/ControlPill";
5import { cn } from "../../lib/cn";20import { cn } from "../../lib/cn";
6import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity";21import { useThemeColor } from "../../lib/useThemeColor";
22import {
23 isPendingUserInputOptionSelected,
24 type PendingUserInput,
25 type PendingUserInputDraftAnswer,
26} from "../../lib/threadActivity";
727
8export interface PendingUserInputCardProps {28export interface PendingUserInputCardProps {
9 readonly pendingUserInput: PendingUserInput;29 readonly pendingUserInput: PendingUserInput;
30 /**
31 * Constant while a request is pending (it reserves keyboard space), so the
32 * keyboard transition is pure translation; changes only on rare discrete
33 * corrections, which the layout transition smooths.
34 */
35 readonly maxHeight: number;
36 readonly collapsed: boolean;
37 readonly onToggleCollapsed: () => void;
38 /** Renders a stop control on the collapsed bar, which replaces the composer. */
39 readonly onStopThread?: () => void;
40 /**
41 * 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the
42 * collapsed bar (inside a clipping window) on the UI thread; the host
43 * animates it directly from the tap handler so the card and the feed
44 * inset glide start the same frame.
45 */
46 readonly cardProgress?: SharedValue<number>;
47 /**
48 * Receives how far the expanded card extends above the bar footprint
49 * (written from onLayout with no re-render); the host adds it to the
50 * thread feed's end inset so the end of the chat stays visible above the
51 * card.
52 */
53 readonly cardCoverage?: SharedValue<number>;
54 /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */
55 readonly onInputFocusChange?: (focused: boolean) => void;
10 readonly drafts: Record<string, PendingUserInputDraftAnswer>;56 readonly drafts: Record<string, PendingUserInputDraftAnswer>;
11 readonly answers: Record<string, string> | null;57 readonly answers: Record<string, string | ReadonlyArray<string>> | null;
12 readonly respondingUserInputId: ApprovalRequestId | null;58 readonly respondingUserInputId: ApprovalRequestId | null;
13 readonly onSelectOption: (59 readonly onSelectOption: (
14 requestId: ApprovalRequestId,60 requestId: ApprovalRequestId,
15 questionId: string,61 question: UserInputQuestion,
16 label: string,62 label: string,
17 ) => void;63 ) => void;
18 readonly onChangeCustomAnswer: (64 readonly onChangeCustomAnswer: (
...@@ -23,74 +69,242 @@ export interface PendingUserInputCardProps {...@@ -23,74 +69,242 @@ export interface PendingUserInputCardProps {
23 readonly onSubmit: () => Promise<unknown>;69 readonly onSubmit: () => Promise<unknown>;
24}70}
2571
72/**
73 * On iOS the collapsed bar is the PERMANENT in-flow footprint — the expanded
74 * card is an absolutely-positioned overlay rising above it. The overlay's
75 * measured height (which drives the thread feed's bottom inset) therefore
76 * never changes on collapse/expand, so the transcript stays perfectly still
77 * while the card animates over it.
78 *
79 * Android cannot use the overlay: it does not hit-test touches outside a
80 * parent's bounds, which made everything above the bar-sized wrapper
81 * untouchable. There the expanded card renders in-flow instead (the wrapper
82 * grows with it, and the host skips the coverage inset since the measured
83 * overlay already includes the card).
84 */
85const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios";
86
87const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200);
88
26export function PendingUserInputCard(props: PendingUserInputCardProps) {89export function PendingUserInputCard(props: PendingUserInputCardProps) {
27 // The surface is opaque on purpose: the card floats over the thread feed90 const iconSubtle = useThemeColor("--color-icon-subtle");
28 // with no blur behind it, so a translucent background renders the questions91 const questionCount = props.pendingUserInput.questions.length;
29 // on top of whatever message happens to sit underneath.92
30 return (93 const cardCoverage = props.cardCoverage;
31 <View className="gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900">94 const barHeightRef = useRef(0);
32 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">95 const cardHeightRef = useRef(0);
33 User input needed96 // Measured card height, written straight from onLayout: the collapse slide
34 </Text>97 // distance. Not animated — it only changes on discrete relayouts.
35 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">98 const cardHeight = useSharedValue(0);
36 Fill in the pending answers99 const notifyCoverage = useCallback(() => {
37 </Text>100 if (!cardCoverage) {
38 {props.pendingUserInput.questions.map((question) => {101 return;
39 const draft = props.drafts[question.id];102 }
40 return (103 const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current);
41 <View key={question.id} className="gap-2 pt-1">104 if (coverage === cardCoverage.value) {
42 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">105 return;
43 {question.header}106 }
44 </Text>107 if (cardCoverage.value === 0) {
45 <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50">108 // First measurement lands while the list is doing its initial
46 {question.question}109 // end-pin (thread opened onto a pending request); animating it from
47 </Text>110 // zero would move the end anchor out from under that scroll.
48 <View className="flex-row flex-wrap gap-2.5">111 cardCoverage.value = coverage;
49 {question.options.map((option) => {112 return;
50 const selected =113 }
51 draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length;114 // Animated so a coverage change at rest (discrete max-height
52 return (115 // corrections) glides the feed instead of stepping it; toggle timing is
53 <Pressable116 // owned by the host's progress values.
54 key={option.label}117 cardCoverage.value = withTiming(coverage, {
55 className={cn(118 duration: USER_INPUT_TOGGLE_DURATION_MS,
56 "rounded-full border px-3 py-2.5 ",119 easing: Easing.out(Easing.cubic),
57 selected120 });
58 ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"121 }, [cardCoverage]);
59 : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70",122 const handleBarLayout = useCallback(
60 )}123 (event: LayoutChangeEvent) => {
61 onPress={() =>124 barHeightRef.current = event.nativeEvent.layout.height;
62 props.onSelectOption(125 notifyCoverage();
63 props.pendingUserInput.requestId,126 },
64 question.id,127 [notifyCoverage],
65 option.label,128 );
66 )129 const handleCardLayout = useCallback(
67 }130 (event: LayoutChangeEvent) => {
68 >131 cardHeightRef.current = event.nativeEvent.layout.height;
69 <Text132 cardHeight.value = event.nativeEvent.layout.height;
133 notifyCoverage();
134 },
135 [cardHeight, notifyCoverage],
136 );
137 const cardProgress = props.cardProgress;
138 // No opacity: fading an opaque card over the live transcript reads as a
139 // crossfade (card text, transcript, and bar all half-visible at once).
140 // Instead the card stays opaque and slides its full height down past the
141 // clipping window's bottom edge, so the transcript is only revealed where
142 // the card has physically left.
143 const cardAnimatedStyle = useAnimatedStyle(() => {
144 const progress = cardProgress === undefined ? 1 : cardProgress.value;
145 return {
146 transform: [{ translateY: (1 - progress) * cardHeight.value }],
147 };
148 });
149
150 // On iOS the card stays MOUNTED while collapsed (hidden via the animated
151 // style): expanding animates existing views on the UI thread the same
152 // frame the host starts the progress timing, instead of paying a React
153 // mount + layout before anything moves.
154 const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed;
155 const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY;
156 // The bar renders UNDER the card (earlier in JSX), always opaque: while
157 // expanded the opaque card covers it, and during the collapse slide the
158 // card's top edge wipes past and reveals it — no opacity handoff, so no
159 // crossfade frames.
160 const bar = showBar ? (
161 <View
162 onLayout={handleBarLayout}
163 pointerEvents={props.collapsed ? "auto" : "none"}
164 accessibilityElementsHidden={!props.collapsed}
165 importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"}
166 className="flex-row items-center gap-2 rounded-full border border-neutral-200 bg-neutral-100 py-1.5 pl-4 pr-1.5 dark:border-white/6 dark:bg-neutral-900"
167 >
168 <Pressable
169 accessibilityRole="button"
170 accessibilityLabel={`Expand user input, ${questionCount} question${
171 questionCount === 1 ? "" : "s"
172 }`}
173 onPress={props.onToggleCollapsed}
174 className="min-h-10 flex-1 flex-row items-center gap-2 active:opacity-70"
175 >
176 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
177 User input needed
178 </Text>
179 <Text className="font-sans text-xs text-neutral-500 dark:text-neutral-400">
180 {questionCount} question{questionCount === 1 ? "" : "s"}
181 </Text>
182 <View className="flex-1" />
183 <SymbolView name="chevron.up" size={12} tintColor={iconSubtle} type="monochrome" />
184 </Pressable>
185 {props.onStopThread ? (
186 <ControlPill
187 accessibilityLabel="Stop"
188 icon="stop.fill"
189 variant="danger"
190 className="h-9 w-9"
191 onPress={props.onStopThread}
192 />
193 ) : null}
194 </View>
195 ) : null;
196 const card = renderCard ? (
197 // The surface is opaque on purpose: the card floats over the thread
198 // feed with no blur behind it, so a translucent background renders
199 // the questions on top of whatever message happens to sit underneath.
200 <Animated.View
201 onLayout={handleCardLayout}
202 pointerEvents={props.collapsed ? "none" : "auto"}
203 accessibilityElementsHidden={props.collapsed}
204 importantForAccessibility={props.collapsed ? "no-hide-descendants" : "auto"}
205 entering={
206 EXPANDED_CARD_IS_OVERLAY
207 ? undefined
208 : FadeInUp.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic))
209 }
210 exiting={
211 EXPANDED_CARD_IS_OVERLAY
212 ? undefined
213 : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic))
214 }
215 layout={CARD_LAYOUT_TRANSITION}
216 className="overflow-hidden gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900"
217 style={
218 EXPANDED_CARD_IS_OVERLAY
219 ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle]
220 : { maxHeight: props.maxHeight }
221 }
222 >
223 <Pressable
224 accessibilityRole="button"
225 accessibilityLabel="Collapse user input"
226 onPress={props.onToggleCollapsed}
227 className="flex-row items-start gap-2"
228 >
229 <View className="flex-1 gap-2.5">
230 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
231 User input needed
232 </Text>
233 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
234 Fill in the pending answers
235 </Text>
236 </View>
237 <View className="h-8 w-8 items-center justify-center rounded-full bg-neutral-200/70 dark:bg-white/8">
238 <SymbolView name="chevron.down" size={13} tintColor={iconSubtle} type="monochrome" />
239 </View>
240 </Pressable>
241 <ScrollView
242 bounces={false}
243 className="min-h-0"
244 contentContainerClassName="gap-2.5 pb-1"
245 keyboardShouldPersistTaps="handled"
246 nestedScrollEnabled
247 showsVerticalScrollIndicator
248 style={{ flexShrink: 1 }}
249 >
250 {props.pendingUserInput.questions.map((question) => {
251 const draft = props.drafts[question.id];
252 return (
253 <View key={question.id} className="gap-2 pt-1">
254 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
255 {question.header}
256 </Text>
257 <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50">
258 {question.question}
259 </Text>
260 <View className="flex-row flex-wrap gap-2.5">
261 {question.options.map((option) => {
262 const selected = isPendingUserInputOptionSelected(draft, option.label);
263 return (
264 <Pressable
265 key={option.label}
70 className={cn(266 className={cn(
71 "font-t3-bold text-sm",267 "rounded-full border px-3 py-2.5 ",
72 selected268 selected
73 ? "text-sky-700 dark:text-sky-300"269 ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"
74 : "text-neutral-600 dark:text-neutral-300",270 : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70",
75 )}271 )}
272 onPress={() =>
273 props.onSelectOption(
274 props.pendingUserInput.requestId,
275 question,
276 option.label,
277 )
278 }
76 >279 >
77 {option.label}280 <Text
78 </Text>281 className={cn(
79 </Pressable>282 "font-t3-bold text-sm",
80 );283 selected
81 })}284 ? "text-sky-700 dark:text-sky-300"
285 : "text-neutral-600 dark:text-neutral-300",
286 )}
287 >
288 {option.label}
289 </Text>
290 </Pressable>
291 );
292 })}
293 </View>
294 <TextInput
295 value={draft?.customAnswer ?? ""}
296 onChangeText={(value) =>
297 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
298 }
299 onFocus={() => props.onInputFocusChange?.(true)}
300 onBlur={() => props.onInputFocusChange?.(false)}
301 placeholder="Or type a custom answer"
302 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"
303 />
82 </View>304 </View>
83 <TextInput305 );
84 value={draft?.customAnswer ?? ""}306 })}
85 onChangeText={(value) =>307 </ScrollView>
86 props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value)
87 }
88 placeholder="Or type a custom answer"
89 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"
90 />
91 </View>
92 );
93 })}
94 <Pressable308 <Pressable
95 className={cn(309 className={cn(
96 "items-center justify-center rounded-2xl px-4 py-3.5",310 "items-center justify-center rounded-2xl px-4 py-3.5",
...@@ -103,6 +317,26 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {...@@ -103,6 +317,26 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
103 >317 >
104 <Text className="font-t3-extrabold text-sm text-white">Submit answers</Text>318 <Text className="font-t3-extrabold text-sm text-white">Submit answers</Text>
105 </Pressable>319 </Pressable>
320 </Animated.View>
321 ) : null;
322 return (
323 <View className="relative">
324 {bar}
325 {EXPANDED_CARD_IS_OVERLAY ? (
326 // Clipping window for the collapse slide: same footprint as the
327 // expanded card, bottom edge on the bar's bottom edge. The sliding
328 // card exits through the bottom edge instead of drawing over the
329 // composer area, wiping the bar (and the transcript) into view.
330 <View
331 pointerEvents={props.collapsed ? "none" : "box-none"}
332 className="absolute inset-x-0 bottom-0 justify-end overflow-hidden"
333 style={{ height: props.maxHeight }}
334 >
335 {card}
336 </View>
337 ) : (
338 card
339 )}
106 </View>340 </View>
107 );341 );
108}342}
apps/mobile/src/features/threads/ThreadComposer.tsx+94-13
...@@ -14,6 +14,7 @@ import {...@@ -14,6 +14,7 @@ import {
14 serializeComposerFileLink,14 serializeComposerFileLink,
15 type ComposerTrigger,15 type ComposerTrigger,
16} from "@t3tools/shared/composerTrigger";16} from "@t3tools/shared/composerTrigger";
17import * as Haptics from "expo-haptics";
17import type { ReactNode } from "react";18import type { ReactNode } from "react";
18import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";19import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
19import {20import {
...@@ -51,7 +52,7 @@ import {...@@ -51,7 +52,7 @@ import {
51 ComposerToolbarScroller,52 ComposerToolbarScroller,
52 ComposerToolbarTrigger,53 ComposerToolbarTrigger,
53} from "../../components/ComposerToolbarTrigger";54} from "../../components/ComposerToolbarTrigger";
54import { ControlPill } from "../../components/ControlPill";55import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
55import { ProviderIcon } from "../../components/ProviderIcon";56import { ProviderIcon } from "../../components/ProviderIcon";
56import type { DraftComposerImageAttachment } from "../../lib/composerImages";57import type { DraftComposerImageAttachment } from "../../lib/composerImages";
57import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";58import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
...@@ -62,9 +63,13 @@ import {...@@ -62,9 +63,13 @@ import {
62 normalizeSearchQuery,63 normalizeSearchQuery,
63 scoreQueryMatch,64 scoreQueryMatch,
64} from "@t3tools/shared/searchRanking";65} from "@t3tools/shared/searchRanking";
65import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";66import {
67 applyProviderOptionSelection,
68 resolveProviderOptionDescriptors,
69} from "../../lib/providerOptions";
66import { useComposerPathSearch } from "../../state/use-composer-path-search";70import { useComposerPathSearch } from "../../state/use-composer-path-search";
67import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";71import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
72import { buildThreadSettingsMenu } from "./thread-settings-menu";
68import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";73import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
69import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";74import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
7075
...@@ -113,6 +118,8 @@ export interface ThreadComposerProps {...@@ -113,6 +118,8 @@ export interface ThreadComposerProps {
113 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;118 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
114 readonly onReconnectEnvironment: () => void;119 readonly onReconnectEnvironment: () => void;
115 readonly onExpandedChange?: (expanded: boolean) => void;120 readonly onExpandedChange?: (expanded: boolean) => void;
121 /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */
122 readonly onEditorFocusChange?: (focused: boolean) => void;
116}123}
117124
118/**125/**
...@@ -307,13 +314,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -307,13 +314,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
307 }314 }
308 }, [inputRef]);315 }, [inputRef]);
309316
317 const onEditorFocusChange = props.onEditorFocusChange;
310 const handleFocus = useCallback(() => {318 const handleFocus = useCallback(() => {
311 setIsFocused(true);319 setIsFocused(true);
312 }, []);320 onEditorFocusChange?.(true);
321 }, [onEditorFocusChange]);
313322
314 const handleBlur = useCallback(() => {323 const handleBlur = useCallback(() => {
315 setIsFocused(false);324 setIsFocused(false);
316 }, []);325 onEditorFocusChange?.(false);
326 }, [onEditorFocusChange]);
317 const showStopAction =327 const showStopAction =
318 props.selectedThread.session?.status === "running" ||328 props.selectedThread.session?.status === "running" ||
319 props.selectedThread.session?.status === "starting";329 props.selectedThread.session?.status === "starting";
...@@ -623,6 +633,61 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -623,6 +633,61 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
623 interactionMode: currentInteractionMode,633 interactionMode: currentInteractionMode,
624 });634 });
625635
636 // iOS gets a native menu on the trigger pill: the everyday adjustments
637 // apply without resigning the keyboard, while "All Settings…" (and the
638 // Android trigger) still route through the sheet, which must dismiss it.
639 const settingsMenu = useMemo(
640 () =>
641 Platform.OS === "ios"
642 ? buildThreadSettingsMenu({
643 providerGroups: threadProviderGroups,
644 selectedModel: currentModelSelection,
645 optionDescriptors: providerOptionDescriptors,
646 runtimeMode: currentRuntimeMode,
647 })
648 : null,
649 [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode],
650 );
651
652 const onUpdateModelSelection = props.onUpdateModelSelection;
653 const onUpdateRuntimeMode = props.onUpdateRuntimeMode;
654 const handleSettingsMenuAction = useCallback(
655 (eventId: string) => {
656 const event = settingsMenu?.events.get(eventId);
657 if (!event) {
658 return;
659 }
660 switch (event.type) {
661 case "select-model":
662 void Haptics.selectionAsync();
663 onUpdateModelSelection(event.option.selection);
664 return;
665 case "set-option": {
666 const options = applyProviderOptionSelection(providerOptionDescriptors, {
667 id: event.optionId,
668 value: event.value,
669 });
670 if (options) {
671 void Haptics.selectionAsync();
672 onUpdateModelSelection({ ...currentModelSelection, options });
673 }
674 return;
675 }
676 case "set-runtime":
677 void Haptics.selectionAsync();
678 onUpdateRuntimeMode(event.mode);
679 return;
680 }
681 },
682 [
683 currentModelSelection,
684 onUpdateModelSelection,
685 onUpdateRuntimeMode,
686 providerOptionDescriptors,
687 settingsMenu,
688 ],
689 );
690
626 return (691 return (
627 <Animated.View692 <Animated.View
628 className="px-4"693 className="px-4"
...@@ -792,15 +857,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer...@@ -792,15 +857,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
792 onPress={() => void props.onPickDraftImages()}857 onPress={() => void props.onPickDraftImages()}
793 showChevron={false}858 showChevron={false}
794 />859 />
795 <ComposerToolbarTrigger860 {settingsMenu ? (
796 accessibilityLabel="Thread settings"861 <ControlPillMenu
797 iconNode={862 actions={settingsMenu.actions}
798 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />863 onPressAction={({ nativeEvent }) => handleSettingsMenuAction(nativeEvent.event)}
799 }864 >
800 label={settingsSummaryLabel}865 <ComposerToolbarTrigger
801 maxWidth={320}866 accessibilityLabel="Thread settings"
802 onPress={settingsSheetPresentation.open}867 iconNode={
803 />868 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
869 }
870 label={settingsSummaryLabel}
871 maxWidth={320}
872 />
873 </ControlPillMenu>
874 ) : (
875 <ComposerToolbarTrigger
876 accessibilityLabel="Thread settings"
877 iconNode={
878 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
879 }
880 label={settingsSummaryLabel}
881 maxWidth={320}
882 onPress={settingsSheetPresentation.open}
883 />
884 )}
804 {showStopAction ? (885 {showStopAction ? (
805 <ComposerToolbarButton886 <ComposerToolbarButton
806 accessibilityLabel="Stop"887 accessibilityLabel="Stop"
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+328-40
...@@ -2,6 +2,7 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect...@@ -2,6 +2,7 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect
2import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";2import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
3import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";3import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
4import type { LegendListRef } from "@legendapp/list/react-native";4import type { LegendListRef } from "@legendapp/list/react-native";
5import { HeaderHeightContext } from "@react-navigation/elements";
5import type {6import type {
6 ApprovalRequestId,7 ApprovalRequestId,
7 EnvironmentId,8 EnvironmentId,
...@@ -13,18 +14,45 @@ import type {...@@ -13,18 +14,45 @@ import type {
13 RuntimeMode,14 RuntimeMode,
14 ServerConfig as T3ServerConfig,15 ServerConfig as T3ServerConfig,
15 ThreadId,16 ThreadId,
17 UserInputQuestion,
16} from "@t3tools/contracts";18} from "@t3tools/contracts";
17import * as Haptics from "expo-haptics";19import * as Haptics from "expo-haptics";
18import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";20import {
19import { Platform, View, type GestureResponderEvent } from "react-native";21 memo,
22 useCallback,
23 useContext,
24 useEffect,
25 useLayoutEffect,
26 useMemo,
27 useRef,
28 useState,
29} from "react";
30import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
31import {
32 AppState,
33 Keyboard,
34 Platform,
35 useColorScheme,
36 useWindowDimensions,
37 View,
38 type GestureResponderEvent,
39} from "react-native";
20import {40import {
21 KeyboardController,41 KeyboardController,
22 KeyboardStickyView,42 KeyboardStickyView,
23 useKeyboardState,43 useKeyboardState,
24} from "react-native-keyboard-controller";44} from "react-native-keyboard-controller";
25import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";45import Animated, {
46 Easing,
47 FadeInDown,
48 FadeOut,
49 useAnimatedReaction,
50 useSharedValue,
51 withTiming,
52} from "react-native-reanimated";
26import { useSafeAreaInsets } from "react-native-safe-area-context";53import { useSafeAreaInsets } from "react-native-safe-area-context";
2754
55import { ControlPill } from "../../components/ControlPill";
28import type { ComposerEditorHandle } from "../../components/ComposerEditor";56import type { ComposerEditorHandle } from "../../components/ComposerEditor";
29import type { StatusTone } from "../../components/StatusPill";57import type { StatusTone } from "../../components/StatusPill";
30import type { DraftComposerImageAttachment } from "../../lib/composerImages";58import type { DraftComposerImageAttachment } from "../../lib/composerImages";
...@@ -38,6 +66,11 @@ import type {...@@ -38,6 +66,11 @@ import type {
38} from "../../lib/threadActivity";66} from "../../lib/threadActivity";
39import { PendingApprovalCard } from "./PendingApprovalCard";67import { PendingApprovalCard } from "./PendingApprovalCard";
40import { PendingUserInputCard } from "./PendingUserInputCard";68import { PendingUserInputCard } from "./PendingUserInputCard";
69import {
70 derivePendingUserInputMaxHeight,
71 ESTIMATED_KEYBOARD_HEIGHT,
72 USER_INPUT_TOGGLE_DURATION_MS,
73} from "./pendingUserInputLayout";
41import {74import {
42 COMPOSER_COLLAPSED_CHROME,75 COMPOSER_COLLAPSED_CHROME,
43 COMPOSER_EXPANDED_CHROME,76 COMPOSER_EXPANDED_CHROME,
...@@ -58,7 +91,7 @@ export interface ThreadDetailScreenProps {...@@ -58,7 +91,7 @@ export interface ThreadDetailScreenProps {
58 readonly respondingApprovalId: ApprovalRequestId | null;91 readonly respondingApprovalId: ApprovalRequestId | null;
59 readonly activePendingUserInput: PendingUserInput | null;92 readonly activePendingUserInput: PendingUserInput | null;
60 readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;93 readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
61 readonly activePendingUserInputAnswers: Record<string, string> | null;94 readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;
62 readonly respondingUserInputId: ApprovalRequestId | null;95 readonly respondingUserInputId: ApprovalRequestId | null;
63 readonly draftMessage: string;96 readonly draftMessage: string;
64 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;97 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
...@@ -93,7 +126,7 @@ export interface ThreadDetailScreenProps {...@@ -93,7 +126,7 @@ export interface ThreadDetailScreenProps {
93 ) => Promise<unknown>;126 ) => Promise<unknown>;
94 readonly onSelectUserInputOption: (127 readonly onSelectUserInputOption: (
95 requestId: ApprovalRequestId,128 requestId: ApprovalRequestId,
96 questionId: string,129 question: UserInputQuestion,
97 label: string,130 label: string,
98 ) => void;131 ) => void;
99 readonly onChangeUserInputCustomAnswer: (132 readonly onChangeUserInputCustomAnswer: (
...@@ -175,8 +208,48 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE...@@ -175,8 +208,48 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE
175 }, [threadId, feed]);208 }, [threadId, feed]);
176}209}
177210
211const USER_INPUT_TOGGLE_TIMING = {
212 duration: USER_INPUT_TOGGLE_DURATION_MS,
213 easing: Easing.out(Easing.cubic),
214};
215
178export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {216export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
179 const insets = useSafeAreaInsets();217 const insets = useSafeAreaInsets();
218 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
219 const liveKeyboardHeight = useKeyboardState((state) => state.height);
220 // Android can swallow the IME hide callbacks when the app is backgrounded
221 // mid keyboard-hide (the reported repro: send — which blurs and starts the
222 // hide — then Home within a second). The keyboard library's height AND
223 // visibility then stay frozen open, so gating the sticky translation on
224 // visibility alone still strands the composer after resume. Quarantine the
225 // translation on every Android resume instead; any sign of a live keyboard
226 // stream — an owned input gaining focus, or any visibility/height movement —
227 // lifts it. A healthy resume sees no visual difference (the translation is
228 // already zero while the keyboard is closed).
229 const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false);
230 useEffect(() => {
231 if (Platform.OS !== "android") {
232 return;
233 }
234 const subscription = AppState.addEventListener("change", (state) => {
235 if (state === "active") {
236 setKeyboardStateSuspect(true);
237 }
238 });
239 return () => {
240 subscription.remove();
241 };
242 }, []);
243 useEffect(() => {
244 setKeyboardStateSuspect(false);
245 }, [isKeyboardVisible, liveKeyboardHeight]);
246 const handleOwnedInputFocusChange = useCallback((focused: boolean) => {
247 if (focused) {
248 setKeyboardStateSuspect(false);
249 }
250 }, []);
251 const windowHeight = useWindowDimensions().height;
252 const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44;
180 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;253 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
181 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);254 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
182 const composerEditorRef = useRef<ComposerEditorHandle>(null);255 const composerEditorRef = useRef<ComposerEditorHandle>(null);
...@@ -187,11 +260,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -187,11 +260,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
187 const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);260 const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);
188 const [composerExpanded, setComposerExpanded] = useState(false);261 const [composerExpanded, setComposerExpanded] = useState(false);
189 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);262 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
190 // Key the safe-area padding on keyboard visibility, not focus: on Android263 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
191 // the back gesture closes the keyboard while the editor stays focused, and264 // Android keys the safe-area padding on keyboard visibility (#5988): the
192 // a focus-keyed inset would leave the toolbar under the gesture bar.265 // back gesture closes the keyboard while the editor stays focused, and a
193 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);266 // focus-keyed inset would leave the toolbar under the gesture bar. iOS must
194 const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12);267 // NOT use visibility — it only flips on keyboardDidHide, after the hide
268 // animation, so the composer would ride down flush to the screen edge and
269 // then snap up into the inset. On iOS blur precedes the hide, so the
270 // focus-keyed inset is already in place while the composer rides down.
271 const composerBottomInset = (Platform.OS === "android" ? isKeyboardVisible : composerExpanded)
272 ? 0
273 : Math.max(insets.bottom, 12);
195 const contentPresentationKind = props.contentPresentation.kind;274 const contentPresentationKind = props.contentPresentation.kind;
196 // The raw sync status enters "synchronizing" on every full fetch, cached or275 // The raw sync status enters "synchronizing" on every full fetch, cached or
197 // not. Whether messages are already on screen decides the pill label: no276 // not. Whether messages are already on screen decides the pill label: no
...@@ -212,6 +291,41 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -212,6 +291,41 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
212 const selectedThreadFeed = props.selectedThreadFeed;291 const selectedThreadFeed = props.selectedThreadFeed;
213 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;292 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
214 const composerOverlapHeight = composerChrome + composerBottomInset;293 const composerOverlapHeight = composerChrome + composerBottomInset;
294 // While a user-input request is pending, the questionnaire owns the
295 // composer slot outright: expanded it is the full card, collapsed it is a
296 // composer-style bar in the same place (with its own stop control). The
297 // composer never mounts into the transition, which keeps the collapse and
298 // keyboard animations coherent. Collapse state is keyed by request id so a
299 // new request re-expands automatically.
300 const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] =
301 useState<ApprovalRequestId | null>(null);
302 const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null;
303 const userInputCollapsed =
304 activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId;
305 // The card's height RESERVES keyboard space at all times instead of
306 // tracking the keyboard: transforms (the sticky translation) apply
307 // same-frame on the UI thread while layout props lag a Yoga pass behind,
308 // so any height that follows the keyboard flashes the card over the nav
309 // header on the way up. With a constant height the keyboard transition is
310 // pure translation — frame-perfect by construction — and the resting card
311 // stays compact over the transcript. Before the first open the reserve is
312 // an estimate; once a real height is known the card corrects once,
313 // discretely.
314 const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0);
315 useEffect(() => {
316 if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) {
317 setLastKnownKeyboardHeight(liveKeyboardHeight);
318 }
319 }, [lastKnownKeyboardHeight, liveKeyboardHeight]);
320 const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({
321 windowHeight,
322 keyboardHeight:
323 lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT,
324 navigationHeaderHeight,
325 // The questionnaire owns the composer slot, so only the composer's
326 // bottom inset still overlaps.
327 composerOverlapHeight: composerBottomInset,
328 });
215 const estimatedOverlayHeight = composerOverlapHeight;329 const estimatedOverlayHeight = composerOverlapHeight;
216 // The overlay's measured height includes the home-indicator inset (the330 // The overlay's measured height includes the home-indicator inset (the
217 // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes331 // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
...@@ -228,7 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -228,7 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
228 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),342 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
229 -nativeInsetOvercount,343 -nativeInsetOvercount,
230 );344 );
345 // The expanded questionnaire is an absolute overlay on iOS, so it never
346 // changes the measured overlay height (that constancy is what keeps the
347 // feed from snapping on collapse/expand). The toggle choreography runs on
348 // SHARED VALUES set directly in the tap handler — one JS hop, then the
349 // card's rise/sink and the feed's end-inset glide animate in lockstep on
350 // the UI thread, keyboard-style, instead of waiting on React mount +
351 // onLayout + state round trips. Coverage (how far the card extends above
352 // the bar) is measured straight into a shared value by the card's
353 // onLayout, with no re-render.
354 const userInputCardProgress = useSharedValue(1);
355 const userInputInsetProgress = useSharedValue(1);
356 const userInputCardCoverage = useSharedValue(0);
357 // Android renders the expanded card in-flow (it cannot hit-test the iOS
358 // overlay outside the bar's bounds), so its measured overlay height already
359 // includes the card — the coverage extra is iOS-only.
360 const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null;
361 const combinedContentInsetEndAdjustment = useSharedValue(
362 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
363 );
364 useAnimatedReaction(
365 () =>
366 contentInsetEndAdjustment.value +
367 (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0),
368 (value) => {
369 combinedContentInsetEndAdjustment.value = value;
370 },
371 [userInputCoverageApplies],
372 );
231 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });373 const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
374 const endFollowEnabledRef = useRef(true);
375 endFollowEnabledRef.current = endFollowEnabled;
376 const userInputRepinTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
377 // The list's own corrections for these inset changes drift on short
378 // content (and the error compounds across toggles), so deterministically
379 // re-pin the end once a toggle settles: a no-op when the resting position
380 // is already right, corrective when it is not. Follow state is re-checked
381 // inside the callback — the user may grab the list during the settle
382 // window, and yanking them back would override a live gesture.
383 const scheduleUserInputRepin = useCallback(
384 (delayMs: number) => {
385 if (userInputRepinTimerRef.current !== null) {
386 clearTimeout(userInputRepinTimerRef.current);
387 }
388 userInputRepinTimerRef.current = setTimeout(() => {
389 userInputRepinTimerRef.current = null;
390 if (!endFollowEnabledRef.current) {
391 return;
392 }
393 void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => {
394 freeze.set(false);
395 });
396 }, delayMs);
397 },
398 [freeze, scrollMessageToEnd],
399 );
400 useEffect(
401 () => () => {
402 if (userInputRepinTimerRef.current !== null) {
403 clearTimeout(userInputRepinTimerRef.current);
404 }
405 },
406 [],
407 );
408 const handleToggleUserInputCollapsed = useCallback(() => {
409 if (activeUserInputRequestId === null) {
410 return;
411 }
412 if (userInputCollapsed) {
413 // Expanding: card and feed glide start NOW, on the UI thread.
414 userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);
415 userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING);
416 setCollapsedUserInputRequestId(null);
417 scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50);
418 } else {
419 // Collapsing hides the custom-answer inputs; release the keyboard with
420 // them instead of leaving it up over a dead responder.
421 Keyboard.dismiss();
422 userInputCardProgress.value = withTiming(0, USER_INPUT_TOGGLE_TIMING);
423 // Instant: the sinking card still covers the strip being revealed, and
424 // animating the inset downward is what drifted the short-content end
425 // anchor.
426 userInputInsetProgress.value = 0;
427 setCollapsedUserInputRequestId(activeUserInputRequestId);
428 scheduleUserInputRepin(60);
429 }
430 }, [
431 activeUserInputRequestId,
432 scheduleUserInputRepin,
433 userInputCardProgress,
434 userInputCollapsed,
435 userInputInsetProgress,
436 ]);
437 useEffect(() => {
438 // A new request always arrives expanded.
439 userInputCardProgress.value = 1;
440 userInputInsetProgress.value = 1;
441 }, [activeUserInputRequestId, userInputCardProgress, userInputInsetProgress]);
232 const showContent = props.showContent ?? true;442 const showContent = props.showContent ?? true;
233 const layoutVariant = props.layoutVariant ?? "compact";443 const layoutVariant = props.layoutVariant ?? "compact";
234 const isSplitLayout = layoutVariant === "split";444 const isSplitLayout = layoutVariant === "split";
...@@ -249,6 +459,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -249,6 +459,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
249 useEffect(() => {459 useEffect(() => {
250 setAnchorMessageId(null);460 setAnchorMessageId(null);
251 lastScrolledAnchorMessageIdRef.current = null;461 lastScrolledAnchorMessageIdRef.current = null;
462 setEndFollowEnabled(true);
252 freeze.set(false);463 freeze.set(false);
253 }, [freeze, selectedThreadKey]);464 }, [freeze, selectedThreadKey]);
254465
...@@ -320,6 +531,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -320,6 +531,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
320 composerEditorRef.current?.blur();531 composerEditorRef.current?.blur();
321 }, []);532 }, []);
322533
534 const handleScrollToEnd = useCallback(() => {
535 void Haptics.selectionAsync();
536 void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
537 freeze.set(false);
538 });
539 }, [freeze, scrollMessageToEnd]);
540
541 const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
542 const isDarkMode = useColorScheme() === "dark";
543
323 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {544 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
324 feedTouchStartRef.current = {545 feedTouchStartRef.current = {
325 pageX: event.nativeEvent.pageX,546 pageX: event.nativeEvent.pageX,
...@@ -373,13 +594,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -373,13 +594,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
373 listRef={listRef}594 listRef={listRef}
374 freeze={freeze}595 freeze={freeze}
375 anchorMessageId={anchorMessageId}596 anchorMessageId={anchorMessageId}
376 contentInsetEndAdjustment={contentInsetEndAdjustment}597 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
377 contentTopInset={0}598 contentTopInset={0}
378 contentBottomInset={estimatedOverlayHeight}599 contentBottomInset={estimatedOverlayHeight}
379 contentMaxWidth={contentMaxWidth}600 contentMaxWidth={contentMaxWidth}
380 layoutVariant={layoutVariant}601 layoutVariant={layoutVariant}
381 usesAutomaticContentInsets={props.usesAutomaticContentInsets}602 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
382 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}603 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
604 onEndFollowEnabledChange={setEndFollowEnabled}
383 skills={selectedProviderSkills}605 skills={selectedProviderSkills}
384 loadEarlier={props.loadEarlier ?? null}606 loadEarlier={props.loadEarlier ?? null}
385 />607 />
...@@ -391,6 +613,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -391,6 +613,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
391 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}613 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
392 {showContent ? (614 {showContent ? (
393 <KeyboardStickyView615 <KeyboardStickyView
616 // The animated keyboard height can remain stale after a dismissed
617 // IME on both platforms. Visibility is the authoritative closed
618 // state, so disable the translation rather than stranding the pill.
619 enabled={isKeyboardVisible && !keyboardStateSuspect}
394 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}620 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
395 offset={{ closed: 0, opened: 0 }}621 offset={{ closed: 0, opened: 0 }}
396 >622 >
...@@ -398,10 +624,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -398,10 +624,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
398 list's bottom inset, so any padding above the pill/composer624 list's bottom inset, so any padding above the pill/composer
399 pushes the resting content floor up by the same amount. */}625 pushes the resting content floor up by the same amount. */}
400 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">626 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
627 {showScrollToEndButton ? (
628 <Animated.View
629 pointerEvents="box-none"
630 className="absolute -top-11 left-0 right-0 z-20 items-center"
631 entering={FadeInDown.duration(160)}
632 exiting={FadeOut.duration(100)}
633 >
634 {isLiquidGlassSupported ? (
635 <LiquidGlassView
636 colorScheme={isDarkMode ? "dark" : "light"}
637 effect="regular"
638 interactive
639 // Interactive glass can render larger than the requested
640 // box (minimum touch size), so center the pill instead of
641 // relying on it filling the glass exactly.
642 style={{
643 alignItems: "center",
644 borderRadius: 18,
645 height: 36,
646 justifyContent: "center",
647 overflow: "hidden",
648 width: 36,
649 }}
650 >
651 <ControlPill
652 accessibilityLabel="Scroll to end"
653 activateOnPressIn
654 className="h-9 w-9 bg-transparent"
655 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
656 onPress={handleScrollToEnd}
657 />
658 </LiquidGlassView>
659 ) : (
660 <ControlPill
661 accessibilityLabel="Scroll to end"
662 activateOnPressIn
663 className="h-9 w-9 border border-border bg-card shadow-md shadow-black/10"
664 icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
665 onPress={handleScrollToEnd}
666 />
667 )}
668 </Animated.View>
669 ) : null}
401 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>670 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
402 {props.activePendingApproval || props.activePendingUserInput ? (671 {props.activePendingApproval || props.activePendingUserInput ? (
403 <Animated.View672 <Animated.View
404 className="shrink-0 gap-3 px-4 pb-3"673 className="shrink-0 gap-3 px-4 pb-3"
674 // The questionnaire replaces the composer, so it must pad
675 // the home indicator the composer normally covers.
676 style={
677 activeUserInputRequestId !== null
678 ? { paddingBottom: composerBottomInset }
679 : undefined
680 }
405 entering={FadeInDown.duration(220)}681 entering={FadeInDown.duration(220)}
406 exiting={FadeOut.duration(140)}682 exiting={FadeOut.duration(140)}
407 >683 >
...@@ -415,6 +691,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -415,6 +691,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
415 {props.activePendingUserInput ? (691 {props.activePendingUserInput ? (
416 <PendingUserInputCard692 <PendingUserInputCard
417 pendingUserInput={props.activePendingUserInput}693 pendingUserInput={props.activePendingUserInput}
694 maxHeight={pendingUserInputMaxHeight}
695 collapsed={userInputCollapsed}
696 onToggleCollapsed={handleToggleUserInputCollapsed}
697 onStopThread={props.onStopThread}
698 cardProgress={userInputCardProgress}
699 cardCoverage={userInputCardCoverage}
700 onInputFocusChange={handleOwnedInputFocusChange}
418 drafts={props.activePendingUserInputDrafts}701 drafts={props.activePendingUserInputDrafts}
419 answers={props.activePendingUserInputAnswers}702 answers={props.activePendingUserInputAnswers}
420 respondingUserInputId={props.respondingUserInputId}703 respondingUserInputId={props.respondingUserInputId}
...@@ -427,35 +710,40 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -427,35 +710,40 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
427 ) : null}710 ) : null}
428 </View>711 </View>
429712
430 <ThreadComposer713 {/* Hidden (not unmounted) while a user-input request owns the
431 editorRef={composerEditorRef}714 composer slot, so composer drafts and editor state survive. */}
432 draftMessage={props.draftMessage}715 <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}>
433 draftAttachments={props.draftAttachments}716 <ThreadComposer
434 placeholder="Ask the repo agent, or run a command…"717 editorRef={composerEditorRef}
435 contentMaxWidth={contentMaxWidth}718 draftMessage={props.draftMessage}
436 connectionState={props.connectionStateLabel}719 draftAttachments={props.draftAttachments}
437 connectionError={props.connectionError}720 placeholder="Ask the repo agent, or run a command…"
438 environmentLabel={props.environmentLabel}721 contentMaxWidth={contentMaxWidth}
439 threadSyncPhase={threadSyncPhase}722 connectionState={props.connectionStateLabel}
440 selectedThread={props.selectedThread}723 connectionError={props.connectionError}
441 serverConfig={props.serverConfig}724 environmentLabel={props.environmentLabel}
442 queueCount={props.selectedThreadQueueCount}725 threadSyncPhase={threadSyncPhase}
443 activeThreadBusy={props.activeThreadBusy}726 selectedThread={props.selectedThread}
444 environmentId={props.environmentId}727 serverConfig={props.serverConfig}
445 projectCwd={props.projectWorkspaceRoot}728 queueCount={props.selectedThreadQueueCount}
446 bottomInset={composerBottomInset}729 activeThreadBusy={props.activeThreadBusy}
447 onChangeDraftMessage={props.onChangeDraftMessage}730 environmentId={props.environmentId}
448 onPickDraftImages={props.onPickDraftImages}731 projectCwd={props.projectWorkspaceRoot}
449 onNativePasteImages={props.onNativePasteImages}732 bottomInset={composerBottomInset}
450 onRemoveDraftImage={props.onRemoveDraftImage}733 onChangeDraftMessage={props.onChangeDraftMessage}
451 onStopThread={props.onStopThread}734 onPickDraftImages={props.onPickDraftImages}
452 onSendMessage={handleSendMessage}735 onNativePasteImages={props.onNativePasteImages}
453 onReconnectEnvironment={props.onReconnectEnvironment}736 onRemoveDraftImage={props.onRemoveDraftImage}
454 onUpdateModelSelection={props.onUpdateThreadModelSelection}737 onStopThread={props.onStopThread}
455 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}738 onSendMessage={handleSendMessage}
456 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}739 onReconnectEnvironment={props.onReconnectEnvironment}
457 onExpandedChange={setComposerExpanded}740 onUpdateModelSelection={props.onUpdateThreadModelSelection}
458 />741 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
742 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
743 onExpandedChange={setComposerExpanded}
744 onEditorFocusChange={handleOwnedInputFocusChange}
745 />
746 </View>
459 </View>747 </View>
460 </KeyboardStickyView>748 </KeyboardStickyView>
461 ) : null}749 ) : null}
apps/mobile/src/features/threads/ThreadFeed.tsx+94-37
...@@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";...@@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
48import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";48import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
49import { useThemeColor } from "../../lib/useThemeColor";49import { useThemeColor } from "../../lib/useThemeColor";
50import { useFontFamily } from "../../lib/useFontFamily";50import { useFontFamily } from "../../lib/useFontFamily";
51import { scopedThreadKey } from "../../lib/scopedEntities";
51import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";52import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
52import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";53import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
53import {54import {
...@@ -89,6 +90,10 @@ import {...@@ -89,6 +90,10 @@ import {
89 type ThreadFeedLatestTurn,90 type ThreadFeedLatestTurn,
90} from "../../lib/threadActivity";91} from "../../lib/threadActivity";
91import type { ThreadContentPresentation } from "./threadContentPresentation";92import type { ThreadContentPresentation } from "./threadContentPresentation";
93import {
94 resolveThreadFeedLiveFollow,
95 type ThreadFeedLiveFollowEvent,
96} from "./thread-feed-live-follow";
92import {97import {
93 collapsedWorkLogHeight,98 collapsedWorkLogHeight,
94 ThreadWorkGroupToggle,99 ThreadWorkGroupToggle,
...@@ -149,6 +154,7 @@ export interface ThreadFeedProps {...@@ -149,6 +154,7 @@ export interface ThreadFeedProps {
149 readonly layoutVariant?: LayoutVariant;154 readonly layoutVariant?: LayoutVariant;
150 readonly usesAutomaticContentInsets?: boolean;155 readonly usesAutomaticContentInsets?: boolean;
151 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;156 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
157 readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
152 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;158 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
153 /** Non-null when older turns exist beyond the loaded window. */159 /** Non-null when older turns exist beyond the loaded window. */
154 readonly loadEarlier?: {160 readonly loadEarlier?: {
...@@ -1324,6 +1330,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1324,6 +1330,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1324 const disclosureAnchorKeyRef = useRef<string | null>(null);1330 const disclosureAnchorKeyRef = useRef<string | null>(null);
1325 const headerMaterialVisibleRef = useRef(false);1331 const headerMaterialVisibleRef = useRef(false);
1326 const previousLatestTurnRef = useRef(props.latestTurn);1332 const previousLatestTurnRef = useRef(props.latestTurn);
1333 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1327 const { width: windowWidth } = useWindowDimensions();1334 const { width: windowWidth } = useWindowDimensions();
1328 const { appearance } = useAppearancePreferences();1335 const { appearance } = useAppearancePreferences();
1329 const [viewportWidth, setViewportWidth] = useState(() =>1336 const [viewportWidth, setViewportWidth] = useState(() =>
...@@ -1342,13 +1349,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1342,13 +1349,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1342 // momentum; only motion inside a session can break follow, so MVCP1349 // momentum; only motion inside a session can break follow, so MVCP
1343 // compensations and programmatic scrolls never strand a follower.1350 // compensations and programmatic scrolls never strand a follower.
1344 const userScrollSessionRef = useRef(false);1351 const userScrollSessionRef = useRef(false);
1345 const setEndFollow = useCallback((enabled: boolean) => {1352 const setEndFollow = useCallback(
1346 if (endFollowEnabledRef.current === enabled) {1353 (enabled: boolean) => {
1347 return;1354 if (endFollowEnabledRef.current === enabled) {
1348 }1355 return;
1349 endFollowEnabledRef.current = enabled;1356 }
1350 setEndFollowEnabled(enabled);1357 endFollowEnabledRef.current = enabled;
1351 }, []);1358 setEndFollowEnabled(enabled);
1359 props.onEndFollowEnabledChange?.(enabled);
1360 },
1361 [props.onEndFollowEnabledChange],
1362 );
1363 const transitionEndFollow = useCallback(
1364 (event: ThreadFeedLiveFollowEvent) => {
1365 setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event));
1366 },
1367 [setEndFollow],
1368 );
1352 const [interactionState, setInteractionState] = useState<{1369 const [interactionState, setInteractionState] = useState<{
1353 readonly copiedRowId: string | null;1370 readonly copiedRowId: string | null;
1354 readonly expandedWorkGroups: Record<string, boolean>;1371 readonly expandedWorkGroups: Record<string, boolean>;
...@@ -1460,40 +1477,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1460,40 +1477,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1460 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the1477 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
1461 // header height back or the material toggles a full header too late.1478 // header height back or the material toggles a full header too late.
1462 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);1479 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
1463 // Latch bookkeeping. LegendList recomputes its inset-aware end distance1480 // LegendList recomputes its inset-aware end distance before invoking
1464 // before invoking this handler, so getState() is current. Returning to1481 // this handler, so getState() is current. Only the actual end re-arms
1465 // the end re-arms follow no matter who scrolled (the user, or our own1482 // follow: its broader maintain-scroll threshold is large enough for a
1466 // scroll-to-end); moving away breaks it only during a user-initiated1483 // streaming chunk to pull a user back before their upward drag escapes.
1467 // scroll session, so MVCP compensations and programmatic repositioning1484 // A live user-scroll session still wins even if the first scroll event
1468 // can never strand a follower.1485 // remains inside LegendList's at-end tolerance.
1469 const listState = props.listRef.current?.getState();1486 const listState = props.listRef.current?.getState();
1470 if (listState) {1487 if (listState) {
1471 if (listState.isWithinMaintainScrollAtEndThreshold) {1488 transitionEndFollow({
1472 setEndFollow(true);1489 type: "scroll",
1473 } else if (userScrollSessionRef.current) {1490 isAtEnd: listState.isAtEnd,
1474 setEndFollow(false);1491 userScrollSessionActive: userScrollSessionRef.current,
1475 }1492 });
1476 }1493 }
1477 },1494 },
1478 [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, setEndFollow],1495 [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow],
1479 );1496 );
1497 const clearUserScrollSettle = useCallback(() => {
1498 if (userScrollSettleTimerRef.current !== null) {
1499 clearTimeout(userScrollSettleTimerRef.current);
1500 userScrollSettleTimerRef.current = null;
1501 }
1502 }, []);
1480 const handleScrollBeginDrag = useCallback(() => {1503 const handleScrollBeginDrag = useCallback(() => {
1504 clearUserScrollSettle();
1481 userScrollSessionRef.current = true;1505 userScrollSessionRef.current = true;
1482 }, []);1506 // Pause before the first scroll event. Otherwise a stream update can run
1483 // The session must survive past finger-lift so momentum that carries the1507 // maintainScrollAtEnd between touch-down and the drag leaving its threshold.
1484 // user away from the end still breaks follow; a drag released with no1508 transitionEndFollow({ type: "user-scroll-begin" });
1485 // momentum ends its session at the release itself, otherwise at momentum1509 }, [clearUserScrollSettle, transitionEndFollow]);
1486 // end. Leaving a session open would let a later animated maintain-scroll1510 const finishUserScroll = useCallback(
1487 // read as user motion and break follow spuriously.1511 (releaseIsAtEnd?: boolean) => {
1488 const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {1512 clearUserScrollSettle();
1489 const velocity = event.nativeEvent.velocity?.y ?? 0;1513 const userScrollSessionActive = userScrollSessionRef.current;
1490 if (Math.abs(velocity) < 0.05) {
1491 userScrollSessionRef.current = false;1514 userScrollSessionRef.current = false;
1515 transitionEndFollow({
1516 type: "user-scroll-end",
1517 // With no momentum, preserve the finger-release position. Streaming
1518 // growth during the native momentum-detection window must not turn a
1519 // release at the live edge into an opt-out from follow.
1520 isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false,
1521 userScrollSessionActive,
1522 });
1523 },
1524 [clearUserScrollSettle, props.listRef, transitionEndFollow],
1525 );
1526 // Finger-lift velocity is not a reliable momentum signal: a gentle fling
1527 // can report zero and still decelerate. Give native momentum a short window
1528 // to announce itself; if it does, onMomentumScrollBegin cancels this fallback
1529 // and the session survives until the settled momentum-end position. This
1530 // mirrors the native-event handoff used by the home thread list's scroll gate.
1531 const handleScrollEndDrag = useCallback(() => {
1532 clearUserScrollSettle();
1533 const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false;
1534 userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160);
1535 }, [clearUserScrollSettle, finishUserScroll, props.listRef]);
1536 const handleMomentumScrollBegin = useCallback(() => {
1537 if (userScrollSessionRef.current) {
1538 clearUserScrollSettle();
1492 }1539 }
1493 }, []);1540 }, [clearUserScrollSettle]);
1494 const handleMomentumScrollEnd = useCallback(() => {1541 const handleMomentumScrollEnd = useCallback(() => {
1495 userScrollSessionRef.current = false;1542 finishUserScroll();
1496 }, []);1543 }, [finishUserScroll]);
1544
1545 useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]);
14971546
1498 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {1547 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
1499 const nextWidth = Math.round(event.nativeEvent.layout.width);1548 const nextWidth = Math.round(event.nativeEvent.layout.width);
...@@ -1502,23 +1551,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1502,23 +1551,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1502 setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));1551 setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
1503 }, []);1552 }, []);
15041553
1554 // Thread identity is env-scoped: two environments can hold the same
1555 // ThreadId, and keying resets (or the list mount) on the bare id would
1556 // carry stale scroll/follow state across an environment switch.
1557 const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId);
1558
1505 useEffect(() => {1559 useEffect(() => {
1506 reportHeaderMaterialVisibility(false);1560 reportHeaderMaterialVisibility(false);
1507 }, [props.threadId, reportHeaderMaterialVisibility]);1561 }, [feedThreadKey, reportHeaderMaterialVisibility]);
15081562
1509 // A thread switch opens pinned to the end; a send explicitly returns to the1563 // A thread switch opens pinned to the end; a send explicitly returns to the
1510 // live edge (ThreadDetailScreen scrolls the new message into place). Both1564 // live edge (ThreadDetailScreen scrolls the new message into place). Both
1511 // re-arm follow regardless of where the user had scrolled before.1565 // re-arm follow regardless of where the user had scrolled before.
1512 useEffect(() => {1566 useEffect(() => {
1567 clearUserScrollSettle();
1513 userScrollSessionRef.current = false;1568 userScrollSessionRef.current = false;
1514 setEndFollow(true);1569 transitionEndFollow({ type: "reset" });
1515 }, [props.threadId, setEndFollow]);1570 }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]);
1516 useEffect(() => {1571 useEffect(() => {
1517 if (props.anchorMessageId !== null) {1572 if (props.anchorMessageId !== null) {
1573 clearUserScrollSettle();
1518 userScrollSessionRef.current = false;1574 userScrollSessionRef.current = false;
1519 setEndFollow(true);1575 transitionEndFollow({ type: "reset" });
1520 }1576 }
1521 }, [props.anchorMessageId, setEndFollow]);1577 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);
15221578
1523 const expandedWorkGroupIds = useMemo(() => {1579 const expandedWorkGroupIds = useMemo(() => {
1524 const ids = new Set<string>();1580 const ids = new Set<string>();
...@@ -1554,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1554,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1554 // initial scroll-to-end computes with a zero end inset and rests one1610 // initial scroll-to-end computes with a zero end inset and rests one
1555 // composer-height short of the end. Layout effect: it must land before the1611 // composer-height short of the end. Layout effect: it must land before the
1556 // list's first positioning tick or the one-shot initial scroll misses it.1612 // list's first positioning tick or the one-shot initial scroll misses it.
1557 const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`;1613 const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;
1558 useLayoutEffect(() => {1614 useLayoutEffect(() => {
1559 const bottom = props.contentInsetEndAdjustment.value;1615 const bottom = props.contentInsetEndAdjustment.value;
1560 if (bottom > 0) {1616 if (bottom > 0) {
...@@ -1921,6 +1977,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1921,6 +1977,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1921 onScroll={handleScroll}1977 onScroll={handleScroll}
1922 onScrollBeginDrag={handleScrollBeginDrag}1978 onScrollBeginDrag={handleScrollBeginDrag}
1923 onScrollEndDrag={handleScrollEndDrag}1979 onScrollEndDrag={handleScrollEndDrag}
1980 onMomentumScrollBegin={handleMomentumScrollBegin}
1924 onMomentumScrollEnd={handleMomentumScrollEnd}1981 onMomentumScrollEnd={handleMomentumScrollEnd}
1925 scrollEventThrottle={16}1982 scrollEventThrottle={16}
1926 ListHeaderComponent={1983 ListHeaderComponent={
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+1-27
...@@ -30,6 +30,7 @@ import { cn } from "../../lib/cn";...@@ -30,6 +30,7 @@ import { cn } from "../../lib/cn";
30import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";30import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
31import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";31import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
32import { useThemeColor } from "../../lib/useThemeColor";32import { useThemeColor } from "../../lib/useThemeColor";
33import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu";
33import { pendingModelAfterPress } from "./thread-settings-sheet-state";34import { pendingModelAfterPress } from "./thread-settings-sheet-state";
34import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";35import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";
3536
...@@ -40,26 +41,6 @@ import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet...@@ -40,26 +41,6 @@ import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet
40 */41 */
41const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]);42const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]);
4243
43/**
44 * Desktop-oriented effort keywords that don't belong in the phone picker.
45 * Prompt-injected values (ultrathink and friends) are filtered from the
46 * descriptor metadata; ultracode is a real option but a workflow trigger, not
47 * a reasoning level. A value set elsewhere still displays, it just isn't
48 * offered.
49 */
50const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]);
51
52const RUNTIME_MODE_CHOICES: ReadonlyArray<{
53 readonly mode: RuntimeMode;
54 readonly label: string;
55 readonly shortLabel: string;
56}> = [
57 { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" },
58 { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" },
59 { mode: "auto", label: "Auto", shortLabel: "Auto" },
60 { mode: "full-access", label: "Full access", shortLabel: "Full" },
61];
62
63/**44/**
64 * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill,45 * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill,
65 * covering model, provider options, runtime mode, and plan mode in one label.46 * covering model, provider options, runtime mode, and plan mode in one label.
...@@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: {...@@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: {
79 ].join(" · ");60 ].join(" · ");
80}61}
8162
82function selectableChoices(descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>) {
83 const injected = new Set(descriptor.promptInjectedValues ?? []);
84 return descriptor.options.filter(
85 (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id),
86 );
87}
88
89function ModelRow(props: {63function ModelRow(props: {
90 readonly option: ModelOption;64 readonly option: ModelOption;
91 readonly selected: boolean;65 readonly selected: boolean;
apps/mobile/src/features/threads/pendingUserInputLayout.test.ts created+38
...@@ -0,0 +1,38 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout";
4
5describe("derivePendingUserInputMaxHeight", () => {
6 it("caps a tall portrait viewport", () => {
7 expect(
8 derivePendingUserInputMaxHeight({
9 windowHeight: 932,
10 keyboardHeight: 0,
11 navigationHeaderHeight: 103,
12 composerOverlapHeight: 94,
13 }),
14 ).toBe(560);
15 });
16
17 it("subtracts the keyboard while editing a custom answer", () => {
18 expect(
19 derivePendingUserInputMaxHeight({
20 windowHeight: 932,
21 keyboardHeight: 336,
22 navigationHeaderHeight: 103,
23 composerOverlapHeight: 94,
24 }),
25 ).toBe(387);
26 });
27
28 it("keeps the fixed action area usable in a short keyboard-open viewport", () => {
29 expect(
30 derivePendingUserInputMaxHeight({
31 windowHeight: 375,
32 keyboardHeight: 240,
33 navigationHeaderHeight: 44,
34 composerOverlapHeight: 94,
35 }),
36 ).toBe(160);
37 });
38});
apps/mobile/src/features/threads/pendingUserInputLayout.ts created+37
...@@ -0,0 +1,37 @@
1const PENDING_USER_INPUT_MAX_HEIGHT = 560;
2const PENDING_USER_INPUT_MIN_HEIGHT = 160;
3const PENDING_USER_INPUT_VERTICAL_GAP = 12;
4
5/**
6 * Reserve for a portrait iPhone keyboard with the QuickType bar until a real
7 * height has been observed. Overestimating only costs card height; an
8 * underestimate would let the card overshoot on the first keyboard open.
9 */
10export const ESTIMATED_KEYBOARD_HEIGHT = 336;
11
12/**
13 * One clock for the questionnaire expand/collapse choreography: the card's
14 * enter/exit and the feed-inset glide must share it or they visibly drift.
15 * Sized for the near-full-height slide (the card travels its own height),
16 * in the same class as the iOS keyboard's ~250ms.
17 */
18export const USER_INPUT_TOGGLE_DURATION_MS = 220;
19
20export function derivePendingUserInputMaxHeight(input: {
21 readonly windowHeight: number;
22 readonly keyboardHeight: number;
23 readonly navigationHeaderHeight: number;
24 readonly composerOverlapHeight: number;
25}): number {
26 const availableHeight =
27 input.windowHeight -
28 Math.max(0, input.keyboardHeight) -
29 Math.max(0, input.navigationHeaderHeight) -
30 Math.max(0, input.composerOverlapHeight) -
31 PENDING_USER_INPUT_VERTICAL_GAP;
32
33 return Math.min(
34 PENDING_USER_INPUT_MAX_HEIGHT,
35 Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight),
36 );
37}
apps/mobile/src/features/threads/thread-feed-live-follow.test.ts created+70
...@@ -0,0 +1,70 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow";
4
5describe("resolveThreadFeedLiveFollow", () => {
6 it("pauses immediately when the user starts scrolling", () => {
7 expect(resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" })).toBe(false);
8 });
9
10 it("stays paused away from the actual end", () => {
11 expect(
12 resolveThreadFeedLiveFollow(false, {
13 type: "scroll",
14 isAtEnd: false,
15 userScrollSessionActive: true,
16 }),
17 ).toBe(false);
18 });
19
20 it("does not mistake programmatic layout compensation for a user scroll", () => {
21 expect(
22 resolveThreadFeedLiveFollow(true, {
23 type: "scroll",
24 isAtEnd: false,
25 userScrollSessionActive: false,
26 }),
27 ).toBe(true);
28 });
29
30 it("does not re-arm at the end while a user scroll session is active", () => {
31 expect(
32 resolveThreadFeedLiveFollow(false, {
33 type: "scroll",
34 isAtEnd: true,
35 userScrollSessionActive: true,
36 }),
37 ).toBe(false);
38 });
39
40 it("re-arms at the actual end only after the user scroll session ends", () => {
41 expect(
42 resolveThreadFeedLiveFollow(false, {
43 type: "user-scroll-end",
44 isAtEnd: true,
45 userScrollSessionActive: true,
46 }),
47 ).toBe(true);
48 expect(
49 resolveThreadFeedLiveFollow(false, {
50 type: "user-scroll-end",
51 isAtEnd: false,
52 userScrollSessionActive: true,
53 }),
54 ).toBe(false);
55 });
56
57 it("ignores momentum-end events from programmatic scrolling", () => {
58 expect(
59 resolveThreadFeedLiveFollow(true, {
60 type: "user-scroll-end",
61 isAtEnd: false,
62 userScrollSessionActive: false,
63 }),
64 ).toBe(true);
65 });
66
67 it("re-arms after an explicit reset", () => {
68 expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true);
69 });
70});
apps/mobile/src/features/threads/thread-feed-live-follow.ts created+35
...@@ -0,0 +1,35 @@
1export type ThreadFeedLiveFollowEvent =
2 | { readonly type: "reset" }
3 | { readonly type: "user-scroll-begin" }
4 | {
5 readonly type: "user-scroll-end";
6 readonly isAtEnd: boolean;
7 readonly userScrollSessionActive: boolean;
8 }
9 | {
10 readonly type: "scroll";
11 readonly isAtEnd: boolean;
12 readonly userScrollSessionActive: boolean;
13 };
14
15export function resolveThreadFeedLiveFollow(
16 current: boolean,
17 event: ThreadFeedLiveFollowEvent,
18): boolean {
19 switch (event.type) {
20 case "reset":
21 return true;
22 case "user-scroll-begin":
23 return false;
24 case "user-scroll-end":
25 return event.userScrollSessionActive ? event.isAtEnd : current;
26 case "scroll":
27 if (event.userScrollSessionActive) {
28 return false;
29 }
30 if (event.isAtEnd) {
31 return true;
32 }
33 return current;
34 }
35}
apps/mobile/src/features/threads/thread-settings-menu.test.ts created+284
...@@ -0,0 +1,284 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts";
4
5import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
6import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu";
7
8function modelOption(
9 model: string,
10 overrides: Partial<Pick<ModelOption, "isDefault" | "isLegacy" | "providerKey">> = {},
11): ModelOption {
12 const providerKey = overrides.providerKey ?? "codex";
13 return {
14 key: `${providerKey}:${model}`,
15 label: model,
16 subtitle: providerKey,
17 providerKey,
18 providerLabel: providerKey === "codex" ? "Codex" : "Claude",
19 providerDriver: providerKey === "codex" ? "codex" : "claudeAgent",
20 isDefault: overrides.isDefault ?? false,
21 isLegacy: overrides.isLegacy ?? false,
22 capabilities: null,
23 selection: {
24 instanceId: ProviderInstanceId.make(providerKey),
25 model,
26 options: [],
27 },
28 };
29}
30
31function group(models: ReadonlyArray<ModelOption>): ProviderGroup {
32 const first = models[0];
33 if (!first) {
34 throw new Error("group requires at least one model");
35 }
36 return {
37 providerKey: first.providerKey,
38 providerLabel: first.providerLabel,
39 models,
40 };
41}
42
43const effortDescriptor: ProviderOptionDescriptor = {
44 id: "effort",
45 label: "Reasoning",
46 type: "select",
47 options: [
48 { id: "low", label: "Low" },
49 { id: "medium", label: "Medium", isDefault: true },
50 { id: "high", label: "High" },
51 { id: "ultrathink", label: "Ultrathink" },
52 { id: "ultracode", label: "Ultracode" },
53 ],
54 currentValue: "high",
55 promptInjectedValues: ["ultrathink"],
56};
57
58const fastModeDescriptor: ProviderOptionDescriptor = {
59 id: "fastMode",
60 label: "Fast mode",
61 type: "boolean",
62 currentValue: false,
63};
64
65function baseInput() {
66 const models = [
67 modelOption("gpt-current", { isDefault: true }),
68 modelOption("gpt-next"),
69 modelOption("gpt-old", { isLegacy: true }),
70 ];
71 return {
72 providerGroups: [group(models)],
73 selectedModel: models[0]?.selection ?? null,
74 optionDescriptors: [effortDescriptor, fastModeDescriptor],
75 runtimeMode: "auto",
76 } as const;
77}
78
79function eventFor(menu: ReturnType<typeof buildThreadSettingsMenu>, id: string | undefined) {
80 return id === undefined ? undefined : menu.events.get(id);
81}
82
83describe("buildThreadSettingsMenu", () => {
84 it("orders the top level as model, options, runtime", () => {
85 const menu = buildThreadSettingsMenu(baseInput());
86
87 expect(menu.actions.map((action) => action.title)).toEqual([
88 "Model",
89 "Reasoning",
90 "Fast mode",
91 "Runtime",
92 ]);
93 });
94
95 it("summarizes the current choice on each submenu row", () => {
96 const menu = buildThreadSettingsMenu(baseInput());
97
98 expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current");
99 expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High");
100 expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto");
101 });
102
103 it("checkmarks the selected model and resolves selection events", () => {
104 const menu = buildThreadSettingsMenu(baseInput());
105
106 const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
107 const current = modelItems.find((action) => action.title === "gpt-current");
108 expect(current?.state).toBe("on");
109 expect(current?.subtitle).toBe("Default");
110 expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off");
111
112 const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id);
113 expect(event?.type).toBe("select-model");
114 expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next");
115 });
116
117 it("folds unselected legacy models behind a nested submenu", () => {
118 const menu = buildThreadSettingsMenu(baseInput());
119
120 const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
121 expect(modelItems.map((action) => action.title)).toEqual([
122 "gpt-current",
123 "gpt-next",
124 "Legacy Models",
125 ]);
126 expect(
127 modelItems
128 .find((action) => action.title === "Legacy Models")
129 ?.subactions?.map((action) => action.title),
130 ).toEqual(["gpt-old"]);
131 });
132
133 it("keeps a selected legacy model in the main list", () => {
134 const input = baseInput();
135 const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy);
136 const menu = buildThreadSettingsMenu({
137 ...input,
138 selectedModel: legacy?.selection ?? null,
139 });
140
141 const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
142 expect(modelItems.map((action) => action.title)).toEqual([
143 "gpt-current",
144 "gpt-next",
145 "gpt-old",
146 ]);
147 expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on");
148 });
149
150 it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => {
151 const menu = buildThreadSettingsMenu({
152 ...baseInput(),
153 optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }],
154 });
155
156 const reasoning = menu.actions.find((action) => action.title === "Reasoning");
157 expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]);
158 // The hidden value stays visible as the current summary; it just can't be
159 // picked from the phone.
160 expect(reasoning?.subtitle).toBe("Ultracode");
161 expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true);
162 });
163
164 it("resolves select-option and runtime events with checkmarked current values", () => {
165 const menu = buildThreadSettingsMenu(baseInput());
166
167 const reasoningItems =
168 menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? [];
169 expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on");
170 expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({
171 type: "set-option",
172 optionId: "effort",
173 value: "low",
174 });
175
176 const runtimeItems =
177 menu.actions.find((action) => action.title === "Runtime")?.subactions ?? [];
178 expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on");
179 expect(
180 eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id),
181 ).toEqual({ type: "set-runtime", mode: "full-access" });
182 });
183
184 it("toggles boolean options with the inverted current value", () => {
185 const menu = buildThreadSettingsMenu(baseInput());
186
187 const fastMode = menu.actions.find((action) => action.title === "Fast mode");
188 expect(fastMode?.state).toBe("off");
189 expect(fastMode?.subactions).toBeUndefined();
190 expect(eventFor(menu, fastMode?.id)).toEqual({
191 type: "set-option",
192 optionId: "fastMode",
193 value: true,
194 });
195
196 const enabled = buildThreadSettingsMenu({
197 ...baseInput(),
198 optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }],
199 });
200 const enabledRow = enabled.actions.find((action) => action.title === "Fast mode");
201 expect(enabledRow?.state).toBe("on");
202 expect(eventFor(enabled, enabledRow?.id)).toEqual({
203 type: "set-option",
204 optionId: "fastMode",
205 value: false,
206 });
207 });
208
209 it("keeps the menu presented only for top-level toggles", () => {
210 const menu = buildThreadSettingsMenu(baseInput());
211
212 // Root-level boolean toggles refresh in place with clean chrome, so they
213 // keep the menu presented.
214 expect(
215 menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented,
216 ).toBe(true);
217
218 // Picks inside nested submenus close the menu: staying presented leaves
219 // the submenu on screen with an expanded-submenu header, and the
220 // bottom-anchored collapse back out drops by the levels' height delta.
221 const expected = undefined;
222 const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
223 const reasoningItems =
224 menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? [];
225 const runtimeItems =
226 menu.actions.find((action) => action.title === "Runtime")?.subactions ?? [];
227 const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter(
228 (action) => action.subactions === undefined,
229 );
230 expect(nestedPicks.length).toBeGreaterThan(0);
231 expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe(
232 true,
233 );
234 });
235
236 it("sections models by provider only when multiple groups are offered", () => {
237 const codexModels = [modelOption("gpt-current", { isDefault: true })];
238 const claudeModels = [modelOption("fable-5", { providerKey: "claude" })];
239 const menu = buildThreadSettingsMenu({
240 providerGroups: [group(codexModels), group(claudeModels)],
241 selectedModel: codexModels[0]?.selection ?? null,
242 optionDescriptors: [],
243 runtimeMode: "auto",
244 });
245
246 const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? [];
247 expect(
248 modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })),
249 ).toEqual([
250 { title: "Codex", inline: true },
251 { title: "Claude", inline: true },
252 ]);
253 const claudeSection = modelItems.find((action) => action.title === "Claude");
254 expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]);
255 });
256
257 const eventTypes = (menu: ReturnType<typeof buildThreadSettingsMenu>) => {
258 const types = new Set<ThreadSettingsMenuEvent["type"]>();
259 for (const event of menu.events.values()) {
260 types.add(event.type);
261 }
262 return types;
263 };
264
265 it("registers an event for every leaf action id", () => {
266 const menu = buildThreadSettingsMenu(baseInput());
267 const leafIds: string[] = [];
268 const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => {
269 for (const item of items) {
270 if (Array.isArray(item.subactions) && item.subactions.length > 0) {
271 collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>);
272 } else if (item.id !== undefined) {
273 leafIds.push(item.id);
274 }
275 }
276 };
277 collect(menu.actions);
278
279 for (const id of leafIds) {
280 expect(menu.events.get(id), `missing event for ${id}`).toBeDefined();
281 }
282 expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"]));
283 });
284});
apps/mobile/src/features/threads/thread-settings-menu.ts created+202
...@@ -0,0 +1,202 @@
1import type { MenuAction } from "@react-native-menu/menu";
2import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts";
3import {
4 getProviderOptionCurrentLabel,
5 getProviderOptionCurrentValue,
6} from "@t3tools/shared/model";
7
8import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
9
10/**
11 * Desktop-oriented effort keywords that don't belong in the phone picker.
12 * Prompt-injected values (ultrathink and friends) are filtered from the
13 * descriptor metadata; ultracode is a real option but a workflow trigger, not
14 * a reasoning level. A value set elsewhere still displays, it just isn't
15 * offered.
16 */
17export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet<string> = new Set(["ultracode"]);
18
19export const RUNTIME_MODE_CHOICES: ReadonlyArray<{
20 readonly mode: RuntimeMode;
21 readonly label: string;
22 readonly shortLabel: string;
23}> = [
24 { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" },
25 { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" },
26 { mode: "auto", label: "Auto", shortLabel: "Auto" },
27 { mode: "full-access", label: "Full access", shortLabel: "Full" },
28];
29
30export function selectableChoices(
31 descriptor: Extract<ProviderOptionDescriptor, { type: "select" }>,
32) {
33 const injected = new Set(descriptor.promptInjectedValues ?? []);
34 return descriptor.options.filter(
35 (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id),
36 );
37}
38
39export type ThreadSettingsMenuEvent =
40 | { readonly type: "select-model"; readonly option: ModelOption }
41 | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean }
42 | { readonly type: "set-runtime"; readonly mode: RuntimeMode };
43
44export type ThreadSettingsMenu = {
45 readonly actions: MenuAction[];
46 /** Menu action id → the change it applies, for the onPressAction dispatch. */
47 readonly events: ReadonlyMap<string, ThreadSettingsMenuEvent>;
48};
49
50/**
51 * Native menu replacement for the thread settings sheet (model, select and
52 * boolean provider options, runtime mode). The menu presents from the
53 * composer pill without resigning the keyboard, so adjusting settings never
54 * bounces focus. A thread is bound to one harness, so the menu covers the
55 * sheet's full surface for existing threads; the sheet remains the Android
56 * and new-task-draft surface.
57 *
58 * Selections apply immediately — the sheet's stage-then-Save flow only exists
59 * because the sheet batches a model change with its option edits.
60 */
61export function buildThreadSettingsMenu(input: {
62 readonly providerGroups: ReadonlyArray<ProviderGroup>;
63 readonly selectedModel: ModelSelection | null;
64 readonly optionDescriptors: ReadonlyArray<ProviderOptionDescriptor>;
65 readonly runtimeMode: RuntimeMode;
66}): ThreadSettingsMenu {
67 const events = new Map<string, ThreadSettingsMenuEvent>();
68 const actions: MenuAction[] = [];
69
70 const isSelected = (option: ModelOption) =>
71 option.selection.instanceId === input.selectedModel?.instanceId &&
72 option.selection.model === input.selectedModel.model;
73
74 // Only top-level leaves (boolean toggles) keep the menu presented (iOS
75 // 16+): the root refreshes in place with clean chrome. Picks inside nested
76 // submenus close the menu — keeping the submenu presented renders an
77 // expanded-submenu header with no way to pop back to the root, and the
78 // bottom-anchored collapse back out travels the levels' height difference.
79 const keepPresented = { keepsMenuPresented: true } as const;
80
81 const modelAction = (option: ModelOption, id: string): MenuAction => {
82 events.set(id, { type: "select-model", option });
83 return {
84 id,
85 title: option.label,
86 ...(option.isDefault ? { subtitle: "Default" } : {}),
87 state: isSelected(option) ? "on" : "off",
88 };
89 };
90
91 const modelItems: MenuAction[] = [];
92 const legacyItems: MenuAction[] = [];
93 let selectedModelLabel: string | undefined;
94 input.providerGroups.forEach((group, groupIndex) => {
95 const groupItems: MenuAction[] = [];
96 group.models.forEach((option, modelIndex) => {
97 if (isSelected(option)) {
98 selectedModelLabel = option.label;
99 }
100 const id = `model:${groupIndex}:${modelIndex}`;
101 // A highlighted legacy model stays in the main list (mirroring the
102 // sheet) so the checkmark isn't hidden behind the Legacy fold.
103 if (option.isLegacy && !isSelected(option)) {
104 legacyItems.push(modelAction(option, id));
105 } else {
106 groupItems.push(modelAction(option, id));
107 }
108 });
109 if (groupItems.length === 0) {
110 return;
111 }
112 // A thread is bound to one harness, so provider sections only appear for
113 // multi-group callers (the new-task draft, if it ever adopts the menu).
114 if (input.providerGroups.length > 1) {
115 modelItems.push({
116 id: `model-group:${groupIndex}`,
117 title: group.providerLabel,
118 displayInline: true,
119 subactions: groupItems,
120 });
121 } else {
122 modelItems.push(...groupItems);
123 }
124 });
125 if (legacyItems.length > 0) {
126 modelItems.push({
127 id: "legacy-models",
128 title: "Legacy Models",
129 subactions: legacyItems,
130 });
131 }
132 if (modelItems.length > 0) {
133 actions.push({
134 id: "model",
135 title: "Model",
136 ...(selectedModelLabel === undefined
137 ? input.selectedModel
138 ? { subtitle: input.selectedModel.model }
139 : {}
140 : { subtitle: selectedModelLabel }),
141 subactions: modelItems,
142 });
143 }
144
145 for (const descriptor of input.optionDescriptors) {
146 if (descriptor.type === "boolean") {
147 const id = `option:${descriptor.id}`;
148 events.set(id, {
149 type: "set-option",
150 optionId: descriptor.id,
151 value: !(descriptor.currentValue ?? false),
152 });
153 actions.push({
154 id,
155 title: descriptor.label,
156 state: descriptor.currentValue ? "on" : "off",
157 attributes: keepPresented,
158 });
159 continue;
160 }
161 const currentValue = getProviderOptionCurrentValue(descriptor);
162 const choices = selectableChoices(descriptor).map((choice): MenuAction => {
163 const id = `option:${descriptor.id}:${choice.id}`;
164 events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id });
165 return {
166 id,
167 title: choice.label,
168 state: choice.id === currentValue ? "on" : "off",
169 };
170 });
171 if (choices.length === 0) {
172 continue;
173 }
174 const currentLabel = getProviderOptionCurrentLabel(descriptor);
175 actions.push({
176 id: `option:${descriptor.id}`,
177 title: descriptor.label,
178 ...(currentLabel === undefined ? {} : { subtitle: currentLabel }),
179 subactions: choices,
180 });
181 }
182
183 const runtimeLabel = RUNTIME_MODE_CHOICES.find(
184 (choice) => choice.mode === input.runtimeMode,
185 )?.label;
186 actions.push({
187 id: "runtime",
188 title: "Runtime",
189 ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }),
190 subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => {
191 const id = `runtime:${choice.mode}`;
192 events.set(id, { type: "set-runtime", mode: choice.mode });
193 return {
194 id,
195 title: choice.label,
196 state: choice.mode === input.runtimeMode ? "on" : "off",
197 };
198 }),
199 });
200
201 return { actions, events };
202}
apps/mobile/src/features/updates/app-updates.test.ts+39
...@@ -32,6 +32,19 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl...@@ -32,6 +32,19 @@ function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateCl
32}32}
3333
34describe("runAppUpdateCheck", () => {34describe("runAppUpdateCheck", () => {
35 it("does nothing while running from the Metro development server", async () => {
36 vi.stubGlobal("__DEV__", true);
37 const client = makeUpdateClient();
38
39 try {
40 await runAppUpdateCheck({ client });
41 } finally {
42 vi.unstubAllGlobals();
43 }
44
45 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
46 });
47
35 it("downloads and restarts when a new update is available", async () => {48 it("downloads and restarts when a new update is available", async () => {
36 const client = makeUpdateClient({49 const client = makeUpdateClient({
37 checkForUpdateAsync: vi.fn(async () => ({50 checkForUpdateAsync: vi.fn(async () => ({
...@@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => {...@@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => {
100 reportError.mockRestore();113 reportError.mockRestore();
101 });114 });
102115
116 it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])(
117 "treats Expo's %s failure as an unavailable update check",
118 async (code) => {
119 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
120 const error = Object.assign(new Error("Updates are unavailable"), { code });
121 const client = makeUpdateClient({
122 checkForUpdateAsync: vi.fn(async () => {
123 throw error;
124 }),
125 });
126 const failures: string[] = [];
127 const states: AppUpdateCheckState[] = [];
128
129 await runAppUpdateCheck({
130 client,
131 onFailure: (message) => failures.push(message),
132 onStateChange: (state) => states.push(state),
133 });
134
135 expect(reportError).not.toHaveBeenCalled();
136 expect(failures).toEqual([]);
137 expect(states).toEqual(["checking", "idle"]);
138 reportError.mockRestore();
139 },
140 );
141
103 it("coalesces overlapping launch and manual checks", async () => {142 it("coalesces overlapping launch and manual checks", async () => {
104 let resolveCheck!: (result: {143 let resolveCheck!: (result: {
105 readonly isAvailable: boolean;144 readonly isAvailable: boolean;
apps/mobile/src/features/updates/app-updates.ts+20-3
...@@ -48,8 +48,17 @@ interface Deferred {...@@ -48,8 +48,17 @@ interface Deferred {
48}48}
4949
50const HIDDEN_UPDATE_TAP_COUNT = 5;50const HIDDEN_UPDATE_TAP_COUNT = 5;
51const UPDATE_CHECK_UNAVAILABLE_ERROR_CODES = new Set([
52 "ERR_NOT_AVAILABLE_IN_DEV_CLIENT",
53 "ERR_UPDATES_DISABLED",
54]);
51let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined;55let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined;
5256
57/** Expo's development launcher reports updates as enabled even though its OTA APIs reject. */
58export function isAppUpdateCheckAvailable(client: Pick<AppUpdateClient, "isEnabled"> = Updates) {
59 return client.isEnabled && !(typeof __DEV__ !== "undefined" && __DEV__);
60}
61
53/**62/**
54 * Keeps the manual update affordance discoverable only to someone deliberately63 * Keeps the manual update affordance discoverable only to someone deliberately
55 * tapping the version row five times.64 * tapping the version row five times.
...@@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): {...@@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): {
7382
74export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> {83export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> {
75 const client = options.client ?? Updates;84 const client = options.client ?? Updates;
76 if (!client.isEnabled) return;85 if (!isAppUpdateCheckAvailable(client)) return;
7786
78 if (appUpdateCheckInFlight) {87 if (appUpdateCheckInFlight) {
79 await observeAppUpdateCheck(appUpdateCheckInFlight, options);88 await observeAppUpdateCheck(appUpdateCheckInFlight, options);
...@@ -207,19 +216,27 @@ function reportUpdateFailure(...@@ -207,19 +216,27 @@ function reportUpdateFailure(
207 fallback: string,216 fallback: string,
208 onFailure: AppUpdateCheckOptions["onFailure"],217 onFailure: AppUpdateCheckOptions["onFailure"],
209): void {218): void {
210 reportAtomCommandResult(result, { label: "app update check" });
211 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;219 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
212 const error = squashAtomCommandFailure(result);220 const error = squashAtomCommandFailure(result);
221 if (isAppUpdateUnavailableError(error)) return;
222
223 reportAtomCommandResult(result, { label: "app update check" });
213 onFailure?.(error instanceof Error ? error.message : fallback);224 onFailure?.(error instanceof Error ? error.message : fallback);
214}225}
215226
227function isAppUpdateUnavailableError(error: unknown): boolean {
228 if (typeof error !== "object" || error === null || !("code" in error)) return false;
229 const code = error.code;
230 return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code);
231}
232
216export function createAppUpdateLaunchCheck(233export function createAppUpdateLaunchCheck(
217 client: AppUpdateClient = Updates,234 client: AppUpdateClient = Updates,
218): () => Promise<void> | undefined {235): () => Promise<void> | undefined {
219 let started = false;236 let started = false;
220237
221 return () => {238 return () => {
222 if (started || !client.isEnabled) return undefined;239 if (started || !isAppUpdateCheckAvailable(client)) return undefined;
223 started = true;240 started = true;
224 return runAppUpdateCheck({ client });241 return runAppUpdateCheck({ client });
225 };242 };
apps/mobile/src/lib/threadActivity.test.ts+95
...@@ -12,12 +12,107 @@ import {...@@ -12,12 +12,107 @@ import {
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
1313
14import {14import {
15 buildPendingUserInputAnswers,
15 buildThreadFeed,16 buildThreadFeed,
16 deriveThreadFeedPresentation,17 deriveThreadFeedPresentation,
18 isPendingUserInputOptionSelected,
19 setPendingUserInputCustomAnswer,
20 togglePendingUserInputOptionSelection,
17 type ThreadFeedActivity,21 type ThreadFeedActivity,
18 type ThreadFeedEntry,22 type ThreadFeedEntry,
19} from "./threadActivity";23} from "./threadActivity";
2024
25const singleSelectQuestion = {
26 id: "runtime",
27 header: "Runtime",
28 question: "Which runtime should be used?",
29 options: [
30 { label: "Go", description: "One binary" },
31 { label: "Node.js", description: "Reuse TypeScript" },
32 ],
33 multiSelect: false,
34} as const;
35
36const multiSelectQuestion = {
37 id: "scope",
38 header: "Scope",
39 question: "Which data should be collected?",
40 options: [
41 { label: "Orders", description: "Receipts" },
42 { label: "Listings", description: "Inventory" },
43 ],
44 multiSelect: true,
45} as const;
46
47describe("pending user input answers", () => {
48 it("replaces single-select options and toggles multi-select options", () => {
49 expect(
50 togglePendingUserInputOptionSelection(
51 singleSelectQuestion,
52 { selectedOptionLabels: ["Go"] },
53 "Node.js",
54 ),
55 ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] });
56
57 const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders");
58 const ordersAndListings = togglePendingUserInputOptionSelection(
59 multiSelectQuestion,
60 orders,
61 "Listings",
62 );
63 expect(ordersAndListings).toEqual({
64 customAnswer: "",
65 selectedOptionLabels: ["Orders", "Listings"],
66 });
67 expect(
68 togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"),
69 ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] });
70
71 const paddedOrders = togglePendingUserInputOptionSelection(
72 multiSelectQuestion,
73 undefined,
74 " Orders ",
75 );
76 expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] });
77 expect(
78 togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "),
79 ).toEqual({ customAnswer: "" });
80 });
81
82 it("builds array answers for multi-select questions", () => {
83 expect(
84 buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
85 runtime: { selectedOptionLabels: ["Go"] },
86 scope: { selectedOptionLabels: ["Orders", "Listings"] },
87 }),
88 ).toEqual({
89 runtime: "Go",
90 scope: ["Orders", "Listings"],
91 });
92 });
93
94 it("clears selected options while a custom answer is active", () => {
95 expect(
96 setPendingUserInputCustomAnswer(
97 { selectedOptionLabels: ["Orders", "Listings"] },
98 "Orders first",
99 ),
100 ).toEqual({ customAnswer: "Orders first" });
101 });
102
103 it("matches selected chips against normalized option labels", () => {
104 expect(
105 isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "),
106 ).toBe(true);
107 expect(
108 isPendingUserInputOptionSelected(
109 { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" },
110 " Orders ",
111 ),
112 ).toBe(false);
113 });
114});
115
21function makeActivity(116function makeActivity(
22 input: Partial<OrchestrationThreadActivity> &117 input: Partial<OrchestrationThreadActivity> &
23 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,118 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
apps/mobile/src/lib/threadActivity.ts+67-9
...@@ -26,7 +26,7 @@ export interface PendingUserInput {...@@ -26,7 +26,7 @@ export interface PendingUserInput {
26}26}
2727
28export interface PendingUserInputDraftAnswer {28export interface PendingUserInputDraftAnswer {
29 readonly selectedOptionLabel?: string;29 readonly selectedOptionLabels?: ReadonlyArray<string>;
30 readonly customAnswer?: string;30 readonly customAnswer?: string;
31}31}
3232
...@@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null {...@@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null {
227 return trimmed.length > 0 ? trimmed : null;227 return trimmed.length > 0 ? trimmed : null;
228}228}
229229
230function normalizeSelectedOptionLabels(
231 value: ReadonlyArray<string> | undefined,
232): ReadonlyArray<string> {
233 if (!Array.isArray(value)) {
234 return [];
235 }
236
237 return Array.from(
238 new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)),
239 );
240}
241
230function resolvePendingUserInputAnswer(242function resolvePendingUserInputAnswer(
243 question: UserInputQuestion,
231 draft: PendingUserInputDraftAnswer | undefined,244 draft: PendingUserInputDraftAnswer | undefined,
232): string | null {245): string | ReadonlyArray<string> | null {
233 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);246 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
234 if (customAnswer) {247 if (customAnswer) {
235 return customAnswer;248 return customAnswer;
236 }249 }
237 return normalizeDraftAnswer(draft?.selectedOptionLabel);250
251 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
252 if (question.multiSelect) {
253 return selectedOptionLabels.length > 0 ? selectedOptionLabels : null;
254 }
255 return selectedOptionLabels[0] ?? null;
238}256}
239257
240/** Codex children settle via task.updated (idle/failed/interrupted), never258/** Codex children settle via task.updated (idle/failed/interrupted), never
...@@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer(...@@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer(
1428 draft: PendingUserInputDraftAnswer | undefined,1446 draft: PendingUserInputDraftAnswer | undefined,
1429 customAnswer: string,1447 customAnswer: string,
1430): PendingUserInputDraftAnswer {1448): PendingUserInputDraftAnswer {
1431 const selectedOptionLabel =1449 const selectedOptionLabels =
1432 customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel;1450 customAnswer.trim().length > 0
1451 ? undefined
1452 : normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
1433 return {1453 return {
1434 customAnswer,1454 customAnswer,
1435 ...(selectedOptionLabel ? { selectedOptionLabel } : {}),1455 ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}),
1456 };
1457}
1458
1459export function isPendingUserInputOptionSelected(
1460 draft: PendingUserInputDraftAnswer | undefined,
1461 optionLabel: string,
1462): boolean {
1463 if (normalizeDraftAnswer(draft?.customAnswer)) {
1464 return false;
1465 }
1466
1467 return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim());
1468}
1469
1470export function togglePendingUserInputOptionSelection(
1471 question: UserInputQuestion,
1472 draft: PendingUserInputDraftAnswer | undefined,
1473 optionLabel: string,
1474): PendingUserInputDraftAnswer {
1475 const normalizedOptionLabel = optionLabel.trim();
1476
1477 if (question.multiSelect) {
1478 const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
1479 const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel)
1480 ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel)
1481 : [...selectedOptionLabels, normalizedOptionLabel];
1482
1483 return {
1484 customAnswer: "",
1485 ...(nextSelectedOptionLabels.length > 0
1486 ? { selectedOptionLabels: nextSelectedOptionLabels }
1487 : {}),
1488 };
1489 }
1490
1491 return {
1492 customAnswer: "",
1493 selectedOptionLabels: [normalizedOptionLabel],
1436 };1494 };
1437}1495}
14381496
1439export function buildPendingUserInputAnswers(1497export function buildPendingUserInputAnswers(
1440 questions: ReadonlyArray<UserInputQuestion>,1498 questions: ReadonlyArray<UserInputQuestion>,
1441 draftAnswers: Record<string, PendingUserInputDraftAnswer>,1499 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
1442): Record<string, string> | null {1500): Record<string, string | ReadonlyArray<string>> | null {
1443 const answers: Record<string, string> = {};1501 const answers: Record<string, string | ReadonlyArray<string>> = {};
14441502
1445 for (const question of questions) {1503 for (const question of questions) {
1446 const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);1504 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
1447 if (!answer) {1505 if (!answer) {
1448 return null;1506 return null;
1449 }1507 }
apps/mobile/src/state/use-selected-thread-requests.ts+18-7
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useCallback, useMemo, useState } from "react";2import { useCallback, useMemo, useState } from "react";
33
4import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts";4import {
5 ApprovalRequestId,
6 type ProviderApprovalDecision,
7 type UserInputQuestion,
8} from "@t3tools/contracts";
5import { Atom } from "effect/unstable/reactivity";9import { Atom } from "effect/unstable/reactivity";
610
7import { threadEnvironment } from "../state/threads";11import { threadEnvironment } from "../state/threads";
...@@ -12,6 +16,7 @@ import {...@@ -12,6 +16,7 @@ import {
12 derivePendingUserInputs,16 derivePendingUserInputs,
13 setPendingUserInputCustomAnswer,17 setPendingUserInputCustomAnswer,
14 sortThreadActivities,18 sortThreadActivities,
19 togglePendingUserInputOptionSelection,
15 type PendingUserInputDraftAnswer,20 type PendingUserInputDraftAnswer,
16} from "../lib/threadActivity";21} from "../lib/threadActivity";
17import { appAtomRegistry } from "./atom-registry";22import { appAtomRegistry } from "./atom-registry";
...@@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make<...@@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make<
23 Record<string, Record<string, PendingUserInputDraftAnswer>>28 Record<string, Record<string, PendingUserInputDraftAnswer>>
24>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts"));29>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts"));
2530
26function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void {31function setUserInputDraftOption(
32 requestKey: string,
33 question: UserInputQuestion,
34 label: string,
35): void {
27 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);36 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
28 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {37 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
29 ...current,38 ...current,
30 [requestKey]: {39 [requestKey]: {
31 ...current[requestKey],40 ...current[requestKey],
32 [questionId]: {41 [question.id]: togglePendingUserInputOptionSelection(
33 selectedOptionLabel: label,42 question,
34 },43 current[requestKey]?.[question.id],
44 label,
45 ),
35 },46 },
36 });47 });
37}48}
...@@ -97,13 +108,13 @@ export function useSelectedThreadRequests() {...@@ -97,13 +108,13 @@ export function useSelectedThreadRequests() {
97 : null;108 : null;
98109
99 const onSelectUserInputOption = useCallback(110 const onSelectUserInputOption = useCallback(
100 (requestId: ApprovalRequestId, questionId: string, label: string) => {111 (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => {
101 if (!selectedThreadShell) {112 if (!selectedThreadShell) {
102 return;113 return;
103 }114 }
104115
105 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);116 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
106 setUserInputDraftOption(requestKey, questionId, label);117 setUserInputDraftOption(requestKey, question, label);
107 },118 },
108 [selectedThreadShell],119 [selectedThreadShell],
109 );120 );
apps/web/package.json+1-1
...@@ -21,7 +21,7 @@...@@ -21,7 +21,7 @@
21 "@dnd-kit/utilities": "^3.2.2",21 "@dnd-kit/utilities": "^3.2.2",
22 "@effect/atom-react": "catalog:",22 "@effect/atom-react": "catalog:",
23 "@formkit/auto-animate": "^0.9.0",23 "@formkit/auto-animate": "^0.9.0",
24 "@legendapp/list": "3.3.3",24 "@legendapp/list": "catalog:",
25 "@lexical/react": "^0.41.0",25 "@lexical/react": "^0.41.0",
26 "@pierre/diffs": "catalog:",26 "@pierre/diffs": "catalog:",
27 "@pierre/trees": "1.0.0-beta.4",27 "@pierre/trees": "1.0.0-beta.4",
patches/@clerk__expo@4.2.0.patch created+79
...@@ -0,0 +1,79 @@
1diff --git a/ios/ClerkAuthNativeView.swift b/ios/ClerkAuthNativeView.swift
2index e76a8be1b1c8faa64ec6dfa83764764094133aff..17b36ad1319765e2b6db0551d32e07d7140e482f 100644
3--- a/ios/ClerkAuthNativeView.swift
4+++ b/ios/ClerkAuthNativeView.swift
5@@ -108,7 +108,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost {
6
7 override func makeHostedController() -> UIViewController? {
8 let hostBackAction: (() -> Void)? = currentHostBackButton
9- ? { [weak self] in self?.onHostBack([:]) }
10+ ? { [weak self] in
11+ guard let self else { return }
12+ if !self.popEnclosingNavigationRoute() {
13+ self.onHostBack([:])
14+ }
15+ }
16 : nil
17
18 return ClerkNativeBridge.shared.makeAuthViewController(
19diff --git a/ios/ClerkNativeViewHost.swift b/ios/ClerkNativeViewHost.swift
20index 0d91f0e749f121595c17bc803663df6ac90e4163..8f8a89df97a54168b8b45d0e9c197ca3953b7028 100644
21--- a/ios/ClerkNativeViewHost.swift
22+++ b/ios/ClerkNativeViewHost.swift
23@@ -5,6 +5,7 @@ public class ClerkNativeViewHost: ExpoView {
24 private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self)
25 private var hasInitialized: Bool = false
26 private var configuredObserver: NSObjectProtocol?
27+ private var isPoppingHostRoute = false
28
29 public required init(appContext: AppContext? = nil) {
30 super.init(appContext: appContext)
31@@ -58,6 +59,30 @@ public class ClerkNativeViewHost: ExpoView {
32
33 func hostedViewDidDetachFromWindow() {}
34
35+ /// Pops the React Navigation route that contains this view without waiting for
36+ /// the JavaScript event loop. React Native Screens reports the native dismissal
37+ /// back to React Navigation so its state remains synchronized.
38+ func popEnclosingNavigationRoute() -> Bool {
39+ guard !isPoppingHostRoute else { return true }
40+
41+ var responder: UIResponder? = self
42+
43+ while let nextResponder = responder?.next {
44+ if let viewController = nextResponder as? UIViewController,
45+ let navigationController = viewController.navigationController,
46+ navigationController.viewControllers.count > 1 {
47+ isPoppingHostRoute = true
48+ if navigationController.popViewController(animated: true) != nil {
49+ return true
50+ }
51+ isPoppingHostRoute = false
52+ }
53+ responder = nextResponder
54+ }
55+
56+ return false
57+ }
58+
59 private func addConfiguredObserver() {
60 guard configuredObserver == nil else { return }
61
62diff --git a/ios/ClerkUserProfileNativeView.swift b/ios/ClerkUserProfileNativeView.swift
63index 12d6248b1dc4b4779b252c9954c2f89145907310..d838283b7adcf49fec2a63bff53251b9bee31bf8 100644
64--- a/ios/ClerkUserProfileNativeView.swift
65+++ b/ios/ClerkUserProfileNativeView.swift
66@@ -44,7 +44,12 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost {
67
68 override func makeHostedController() -> UIViewController? {
69 let hostBackAction: (() -> Void)? = currentHostBackButton
70- ? { [weak self] in self?.onHostBack([:]) }
71+ ? { [weak self] in
72+ guard let self else { return }
73+ if !self.popEnclosingNavigationRoute() {
74+ self.onHostBack([:])
75+ }
76+ }
77 : nil
78
79 return ClerkNativeBridge.shared.makeUserProfileViewController(
patches/@legendapp__list@3.3.3.patch deleted-1014
...@@ -1,1014 +0,0 @@
1diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 7bc3bb8..75ec120 100644
3--- a/keyboard.d.ts
4+++ b/keyboard.d.ts
5@@ -277,7 +277,7 @@ type KeyboardChatComposerInsetListRef = {
6 type KeyboardChatComposerRef = {
7 current: Pick<View, "measure"> | null;
8 };
9-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): {
10+declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): {
11 contentInsetEndAdjustment: SharedValue<number>;
12 onComposerLayout: (event: LayoutChangeEvent) => void;
13 };
14@@ -286,8 +286,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb
15 scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>;
16 };
17 declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & {
18+ adjustedInsetCompensation?: number;
19 anchoredEndSpace?: AnchoredEndSpaceConfig;
20 contentInsetEndAdjustment?: SharedValue<number>;
21+ contentInsetEndStaticAdjustment?: number;
22 keyboardOffset?: number;
23 } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;
24
25diff --git a/keyboard.js b/keyboard.js
26index 736286a..8218172 100644
27--- a/keyboard.js
28+++ b/keyboard.js
29@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.
30 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
31 );
32 }
33-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
34+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {
35 const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight);
36 const lastHeightRef = React.useRef(void 0);
37 const reportHeight = React.useCallback(
38- (height) => {
39+ (rawHeight) => {
40+ const height = Math.max(0, rawHeight + heightAdjustment);
41 var _a;
42 if (Number.isFinite(height) && height !== lastHeightRef.current) {
43 lastHeightRef.current = height;
44 contentInsetEndAdjustment.value = height;
45- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
46 }
47 },
48- [contentInsetEndAdjustment, listRef]
49+ [contentInsetEndAdjustment, heightAdjustment, listRef]
50 );
51 React.useLayoutEffect(() => {
52 var _a;
53@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
54 }
55 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
56 const {
57+ adjustedInsetCompensation,
58 anchoredEndSpace,
59 applyWorkaroundForContentInsetHitTestBug,
60 contentInsetEndAdjustment,
61+ contentInsetEndStaticAdjustment,
62 freeze,
63 keyboardLiftBehavior,
64 keyboardOffset,
65@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
66 includeInEndInset: true,
67 onSizeChanged: (size) => {
68 var _a;
69- blankSpace.value = size;
70+ // The anchored blank is sized against the ADJUSTED viewport, but the
71+ // scroll component writes it as a raw contentInset that UIKit tops up
72+ // with the safe-area bottom — write it net of that or the end rest
73+ // (and the anchored message) sinks one safe-area under the header.
74+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
75 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
76 }
77 };
78- }, [anchoredEndSpace, blankSpace]);
79+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
80 const onContentInsetChange = React.useCallback((insets) => {
81 var _a;
82 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
83@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
84 reactNativeKeyboardController.KeyboardChatScrollView,
85 {
86 ...scrollProps,
87+ adjustedInsetCompensation,
88 applyWorkaroundForContentInsetHitTestBug,
89 blankSpace,
90 extraContentPadding: contentInsetEndAdjustment,
91@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
92 );
93 },
94 [
95+ adjustedInsetCompensation,
96 applyWorkaroundForContentInsetHitTestBug,
97 blankSpace,
98 contentInsetEndAdjustment,
99@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
100 AnimatedLegendListInternal,
101 {
102 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
103+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
104 ref: combinedRef,
105 renderScrollComponent: memoList,
106 ...rest
107diff --git a/keyboard.mjs b/keyboard.mjs
108index c1dd270..cb0d142 100644
109--- a/keyboard.mjs
110+++ b/keyboard.mjs
111@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {
112 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
113 );
114 }
115-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
116+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {
117 const contentInsetEndAdjustment = useSharedValue(initialHeight);
118 const lastHeightRef = useRef(void 0);
119 const reportHeight = useCallback(
120- (height) => {
121+ (rawHeight) => {
122+ const height = Math.max(0, rawHeight + heightAdjustment);
123 var _a;
124 if (Number.isFinite(height) && height !== lastHeightRef.current) {
125 lastHeightRef.current = height;
126 contentInsetEndAdjustment.value = height;
127- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
128 }
129 },
130- [contentInsetEndAdjustment, listRef]
131+ [contentInsetEndAdjustment, heightAdjustment, listRef]
132 );
133 useLayoutEffect(() => {
134 var _a;
135@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
136 }
137 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
138 const {
139+ adjustedInsetCompensation,
140 anchoredEndSpace,
141 applyWorkaroundForContentInsetHitTestBug,
142 contentInsetEndAdjustment,
143+ contentInsetEndStaticAdjustment,
144 freeze,
145 keyboardLiftBehavior,
146 keyboardOffset,
147@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
148 includeInEndInset: true,
149 onSizeChanged: (size) => {
150 var _a;
151- blankSpace.value = size;
152+ // The anchored blank is sized against the ADJUSTED viewport, but the
153+ // scroll component writes it as a raw contentInset that UIKit tops up
154+ // with the safe-area bottom — write it net of that or the end rest
155+ // (and the anchored message) sinks one safe-area under the header.
156+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
157 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
158 }
159 };
160- }, [anchoredEndSpace, blankSpace]);
161+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
162 const onContentInsetChange = useCallback((insets) => {
163 var _a;
164 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
165@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
166 KeyboardChatScrollView,
167 {
168 ...scrollProps,
169+ adjustedInsetCompensation,
170 applyWorkaroundForContentInsetHitTestBug,
171 blankSpace,
172 extraContentPadding: contentInsetEndAdjustment,
173@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
174 );
175 },
176 [
177+ adjustedInsetCompensation,
178 applyWorkaroundForContentInsetHitTestBug,
179 blankSpace,
180 contentInsetEndAdjustment,
181@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
182 AnimatedLegendListInternal,
183 {
184 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
185+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
186 ref: combinedRef,
187 renderScrollComponent: memoList,
188 ...rest
189diff --git a/react-native.d.ts b/react-native.d.ts
190index 8204015..cdeaab7 100644
191--- a/react-native.d.ts
192+++ b/react-native.d.ts
193@@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
194 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
195 */
196 contentInsetEndAdjustment?: number;
197+ /**
198+ * Width/height of a leading content inset applied natively outside the list's knowledge
199+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
200+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
201+ */
202+ contentInsetStartAdjustment?: number;
203 /**
204 * Number of columns to render items in.
205 * @default 1
206diff --git a/react-native.js b/react-native.js
207index 229f09a..2a1ceb6 100644
208--- a/react-native.js
209+++ b/react-native.js
210@@ -930,7 +930,7 @@ function setInitialRenderState(ctx, {
211 if (didInitialScroll) {
212 state.didFinishInitialScroll = true;
213 }
214- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
215+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
216 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
217 set$(ctx, "readyToRender", true);
218 setAdaptiveRender(ctx, "normal", "ready");
219@@ -1259,18 +1259,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
220 }
221
222 // src/core/clampScrollOffset.ts
223+function getContentInsetStartAdjustment(ctx) {
224+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
225+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
226+}
227 function clampScrollOffset(ctx, offset, scrollTarget) {
228 const state = ctx.state;
229 const contentSize = getContentSize(ctx);
230+ const minOffset = -getContentInsetStartAdjustment(ctx);
231 let clampedOffset = offset;
232 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
233- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
234+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
235 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
236 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
237 const maxOffset = baseMaxOffset + extraEndOffset;
238 clampedOffset = Math.min(offset, maxOffset);
239 }
240- clampedOffset = Math.max(0, clampedOffset);
241+ clampedOffset = Math.max(minOffset, clampedOffset);
242 return clampedOffset;
243 }
244
245@@ -1406,10 +1411,10 @@ function checkFinishedScrollFrame(ctx) {
246 finishScrollTo(ctx);
247 }
248 }
249-function scrollToFallbackOffset(ctx, offset) {
250+function scrollToFallbackOffset(ctx, offset, animated) {
251 var _a3;
252 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
253- animated: false,
254+ animated: !!animated,
255 x: ctx.state.props.horizontal ? offset : 0,
256 y: ctx.state.props.horizontal ? 0 : offset
257 });
258@@ -1456,7 +1461,10 @@ function checkFinishedScrollFallback(ctx) {
259 });
260 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
261 } else if (shouldRetryUnalignedEndScroll) {
262- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
263+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
264+ if (!isActivelyAnimatingToEnd) {
265+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
266+ }
267 scheduleFallbackCheck(100);
268 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
269 finishScrollTo(ctx);
270@@ -1517,9 +1525,18 @@ function doMaintainScrollAtEnd(ctx) {
271 }
272 if (shouldMaintainScrollAtEnd) {
273 state.pendingMaintainScrollAtEnd = false;
274+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
275+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
276+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
277+ return false;
278+ }
279 const contentSize = getContentSize(ctx);
280+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
281 if (contentSize < state.scrollLength) {
282- state.scroll = 0;
283+ state.scroll = -maintainInsetStartAdjustment;
284+ if (maintainInsetStartAdjustment > 0) {
285+ return true;
286+ }
287 }
288 if (!state.maintainingScrollAtEnd) {
289 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
290@@ -1539,9 +1556,18 @@ function doMaintainScrollAtEnd(ctx) {
291 y: 0
292 });
293 } else {
294- scroller == null ? void 0 : scroller.scrollToEnd({
295- animated: maintainScrollAtEnd.animated
296- });
297+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
298+ if (insetStartAdjustment > 0) {
299+ scroller == null ? void 0 : scroller.scrollTo({
300+ animated: maintainScrollAtEnd.animated,
301+ x: 0,
302+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
303+ });
304+ } else {
305+ scroller == null ? void 0 : scroller.scrollToEnd({
306+ animated: maintainScrollAtEnd.animated
307+ });
308+ }
309 }
310 setTimeout(
311 () => {
312@@ -1571,6 +1597,10 @@ function doMaintainScrollAtEnd(ctx) {
313 function requestAdjust(ctx, positionDiff, dataChanged) {
314 const state = ctx.state;
315 if (Math.abs(positionDiff) > 0.1) {
316+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
317+ // size adjustment are already compensated by a contentOffset shift, so
318+ // animating them would make rows visibly lurch and slide back.
319+ state.lastMVCPAdjustTime = Date.now();
320 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
321 const doit = () => {
322 if (needsScrollWorkaround) {
323@@ -1674,7 +1704,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
324 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
325 return 0;
326 }
327- const maxScroll = Math.max(0, totalSize - state.scrollLength);
328+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
329+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
330+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
331 const clampDelta = maxScroll - state.scroll;
332 if (unresolvedAmount < 0) {
333 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
334@@ -1736,7 +1768,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
335 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
336 return true;
337 }
338- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
339+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
340 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
341 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
342 if (isAtExpectedNativeClamp) {
343@@ -1869,7 +1901,7 @@ function prepareMVCP(ctx, dataChanged) {
344 if (diff > 0) {
345 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
346 } else {
347- const maxScroll = Math.max(0, totalSize - state.scrollLength);
348+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
349 state.scroll = maxScroll;
350 state.scrollPending = maxScroll;
351 diff = 0;
352@@ -2274,8 +2306,121 @@ function scrollToIndex(ctx, {
353 }
354
355 // src/core/initialScroll.ts
356+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
357+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
358+var INSET_END_REVEAL_STABLE_FRAMES = 3;
359+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
360+function startInsetEndSettleWatchdog(ctx) {
361+ const state = ctx.state;
362+ if (state.insetEndSettleWatchdogActive) {
363+ return;
364+ }
365+ state.insetEndSettleWatchdogActive = true;
366+ state.didUserDrag = false;
367+ // Hold the readyToRender opacity gate until the end landing is stable, so
368+ // the estimated-to-measured settle chase happens before first VISIBLE
369+ // paint instead of in front of the user. Capped so slow measurement can
370+ // never hide content for long.
371+ state.insetEndRevealHold = true;
372+ let frames = 0;
373+ let settledFrames = 0;
374+ let revealStableFrames = 0;
375+ const releaseRevealHold = () => {
376+ if (state.insetEndRevealHold) {
377+ state.insetEndRevealHold = false;
378+ setInitialRenderState(ctx, {});
379+ }
380+ };
381+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
382+ // can move the end target one more time — do that upgrade while still
383+ // hidden (phase 1), then require stability again before revealing (phase 2)
384+ // so the first visible frame is the settled one.
385+ let revealPhase = 1;
386+ const onRevealStability = () => {
387+ var _a3;
388+ if (!state.insetEndRevealHold) {
389+ return;
390+ }
391+ if (revealPhase === 1) {
392+ revealPhase = 2;
393+ revealStableFrames = 0;
394+ setAdaptiveRender(ctx, "normal");
395+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
396+ return;
397+ }
398+ releaseRevealHold();
399+ };
400+ const tick = () => {
401+ // The user owns the scroll from their first drag — a re-pin here would
402+ // pin the list under their finger.
403+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
404+ state.insetEndSettleWatchdogActive = false;
405+ releaseRevealHold();
406+ return;
407+ }
408+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
409+ releaseRevealHold();
410+ }
411+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
412+ const contentSize = getContentSize(ctx);
413+ const scrollLength = state.scrollLength;
414+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
415+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
416+ const distance = endOffset - state.scroll;
417+ // Estimated row sizes converging to measured ones can strand the initial
418+ // end landing when the library's own end-anchor bookkeeping gives up.
419+ // While still near the end (never fighting a user who scrolled away),
420+ // re-pin to the current true end until sizes stop changing.
421+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
422+ settledFrames = 0;
423+ revealStableFrames = 0;
424+ const scroller = state.refScroller.current;
425+ if (scroller) {
426+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
427+ }
428+ } else {
429+ settledFrames++;
430+ revealStableFrames++;
431+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
432+ onRevealStability();
433+ }
434+ }
435+ } else {
436+ // Conditions that make re-pinning unnecessary (underflow, in-flight
437+ // programmatic scroll) count toward stability for the reveal.
438+ revealStableFrames++;
439+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
440+ onRevealStability();
441+ }
442+ }
443+ requestAnimationFrame(tick);
444+ };
445+ requestAnimationFrame(tick);
446+}
447 function dispatchInitialScroll(ctx, params) {
448 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
449+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
450+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
451+ if (ctx.state.scrollLength <= 0) {
452+ // The scroll length is unknown, so the resolved target is meaningless;
453+ // the initial-scroll machinery re-advances after layout.
454+ return;
455+ }
456+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
457+ // Content underflows the viewport and nothing has scrolled yet: the
458+ // untouched native rest position (UIKit's adjustedContentInset) IS the
459+ // end position. Dispatching a negative scroll here would race UIKit's
460+ // inset application during screen attach, so just finish the session.
461+ finishInitialScroll(ctx, {
462+ resolvedOffset
463+ });
464+ return;
465+ }
466+ ctx.state.didDispatchInsetInitialScroll = true;
467+ if (target.viewPosition === 1) {
468+ startInsetEndSettleWatchdog(ctx);
469+ }
470+ }
471 const requestedIndex = target.index;
472 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
473 const itemSize = getItemSizeAtIndex(ctx, index);
474@@ -2704,7 +2849,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
475 return;
476 }
477 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
478- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
479+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
480+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
481+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
482 if (!shouldKeepEndTargetAlive) {
483 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
484 clearPendingInitialScrollFooterLayout(ctx, {
485@@ -4637,7 +4784,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
486 }
487 contentBelowAnchor += footerSize + stylePaddingBottom;
488 isReady = !hasUnknownTailSize;
489- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
490+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
491+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
492 } else if (anchorIndex >= 0) {
493 isReady = false;
494 }
495@@ -4655,6 +4803,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
496 updateScroll(ctx, state.scroll, true);
497 }
498 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
499+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
500+ set$(ctx, "anchoredEndSpaceSize", nextSize);
501+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
502+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
503+ updateScroll(ctx, state.scroll, true);
504+ }
505 }
506 return nextSize;
507 }
508@@ -6960,6 +7114,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
509 dataVersion,
510 drawDistance = 250,
511 contentInsetEndAdjustment,
512+ contentInsetStartAdjustment,
513 estimatedItemSize = 100,
514 estimatedListSize,
515 extraData,
516@@ -6990,6 +7145,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
517 onLayout: onLayoutProp,
518 onLoad,
519 onMomentumScrollEnd,
520+ onScrollBeginDrag,
521 onRefresh,
522 onScroll: onScrollProp,
523 onScrollBeginDrag,
524@@ -7076,7 +7232,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
525 const combinedRef = useCombinedRef(refScroller, refScrollView);
526 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
527 const stickyHeaderIndices = stickyHeaderIndicesProp;
528- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
529+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
530 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
531 const alwaysRenderIndices = React2.useMemo(() => {
532 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
533@@ -7215,6 +7371,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
534 contentContainerAlignItems: contentContainerStyle.alignItems,
535 contentInset,
536 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
537+ contentInsetStartAdjustment,
538 data: dataProp,
539 dataKey,
540 dataVersion,
541@@ -7303,6 +7460,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
542 return void 0;
543 }
544 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
545+ if (getContentInsetStartAdjustment(ctx) > 0) {
546+ // With a native leading inset (transparent header + automatic insets),
547+ // any mount-time contentOffset races UIKit's adjustedContentInset
548+ // application. The gated initial-scroll dispatch positions the list once
549+ // real sizes are known instead.
550+ return void 0;
551+ }
552 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
553 }, [usesBootstrapInitialScroll]);
554 React2.useLayoutEffect(() => {
555@@ -7526,6 +7690,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
556 onScroll: (event) => onScroll(ctx, event),
557 onScrollBeginDrag: (event) => {
558 var _a4, _b2;
559+ ctx.state.didUserDrag = true;
560 prepareReachedEdgeForNextUserScroll(ctx);
561 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
562 },
563@@ -7555,6 +7720,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
564 onLayout,
565 onLayoutFooter,
566 onMomentumScrollEnd: fns.onMomentumScrollEnd,
567+ onScrollBeginDrag: fns.onScrollBeginDrag,
568 onScroll: onScrollHandler,
569 recycleItems,
570 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
571diff --git a/react-native.mjs b/react-native.mjs
572index c2e0f38..5313086 100644
573--- a/react-native.mjs
574+++ b/react-native.mjs
575@@ -909,7 +909,7 @@ function setInitialRenderState(ctx, {
576 if (didInitialScroll) {
577 state.didFinishInitialScroll = true;
578 }
579- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
580+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
581 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
582 set$(ctx, "readyToRender", true);
583 setAdaptiveRender(ctx, "normal", "ready");
584@@ -1238,18 +1238,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
585 }
586
587 // src/core/clampScrollOffset.ts
588+function getContentInsetStartAdjustment(ctx) {
589+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
590+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
591+}
592 function clampScrollOffset(ctx, offset, scrollTarget) {
593 const state = ctx.state;
594 const contentSize = getContentSize(ctx);
595+ const minOffset = -getContentInsetStartAdjustment(ctx);
596 let clampedOffset = offset;
597 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
598- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
599+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
600 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
601 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
602 const maxOffset = baseMaxOffset + extraEndOffset;
603 clampedOffset = Math.min(offset, maxOffset);
604 }
605- clampedOffset = Math.max(0, clampedOffset);
606+ clampedOffset = Math.max(minOffset, clampedOffset);
607 return clampedOffset;
608 }
609
610@@ -1385,10 +1390,10 @@ function checkFinishedScrollFrame(ctx) {
611 finishScrollTo(ctx);
612 }
613 }
614-function scrollToFallbackOffset(ctx, offset) {
615+function scrollToFallbackOffset(ctx, offset, animated) {
616 var _a3;
617 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
618- animated: false,
619+ animated: !!animated,
620 x: ctx.state.props.horizontal ? offset : 0,
621 y: ctx.state.props.horizontal ? 0 : offset
622 });
623@@ -1435,7 +1440,10 @@ function checkFinishedScrollFallback(ctx) {
624 });
625 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
626 } else if (shouldRetryUnalignedEndScroll) {
627- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
628+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
629+ if (!isActivelyAnimatingToEnd) {
630+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
631+ }
632 scheduleFallbackCheck(100);
633 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
634 finishScrollTo(ctx);
635@@ -1496,9 +1504,18 @@ function doMaintainScrollAtEnd(ctx) {
636 }
637 if (shouldMaintainScrollAtEnd) {
638 state.pendingMaintainScrollAtEnd = false;
639+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
640+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
641+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
642+ return false;
643+ }
644 const contentSize = getContentSize(ctx);
645+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
646 if (contentSize < state.scrollLength) {
647- state.scroll = 0;
648+ state.scroll = -maintainInsetStartAdjustment;
649+ if (maintainInsetStartAdjustment > 0) {
650+ return true;
651+ }
652 }
653 if (!state.maintainingScrollAtEnd) {
654 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
655@@ -1518,9 +1535,18 @@ function doMaintainScrollAtEnd(ctx) {
656 y: 0
657 });
658 } else {
659- scroller == null ? void 0 : scroller.scrollToEnd({
660- animated: maintainScrollAtEnd.animated
661- });
662+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
663+ if (insetStartAdjustment > 0) {
664+ scroller == null ? void 0 : scroller.scrollTo({
665+ animated: maintainScrollAtEnd.animated,
666+ x: 0,
667+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
668+ });
669+ } else {
670+ scroller == null ? void 0 : scroller.scrollToEnd({
671+ animated: maintainScrollAtEnd.animated
672+ });
673+ }
674 }
675 setTimeout(
676 () => {
677@@ -1550,6 +1576,10 @@ function doMaintainScrollAtEnd(ctx) {
678 function requestAdjust(ctx, positionDiff, dataChanged) {
679 const state = ctx.state;
680 if (Math.abs(positionDiff) > 0.1) {
681+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
682+ // size adjustment are already compensated by a contentOffset shift, so
683+ // animating them would make rows visibly lurch and slide back.
684+ state.lastMVCPAdjustTime = Date.now();
685 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
686 const doit = () => {
687 if (needsScrollWorkaround) {
688@@ -1653,7 +1683,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
689 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
690 return 0;
691 }
692- const maxScroll = Math.max(0, totalSize - state.scrollLength);
693+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
694+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
695+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
696 const clampDelta = maxScroll - state.scroll;
697 if (unresolvedAmount < 0) {
698 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
699@@ -1715,7 +1747,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
700 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
701 return true;
702 }
703- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
704+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
705 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
706 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
707 if (isAtExpectedNativeClamp) {
708@@ -1848,7 +1880,7 @@ function prepareMVCP(ctx, dataChanged) {
709 if (diff > 0) {
710 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
711 } else {
712- const maxScroll = Math.max(0, totalSize - state.scrollLength);
713+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
714 state.scroll = maxScroll;
715 state.scrollPending = maxScroll;
716 diff = 0;
717@@ -2253,8 +2285,121 @@ function scrollToIndex(ctx, {
718 }
719
720 // src/core/initialScroll.ts
721+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
722+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
723+var INSET_END_REVEAL_STABLE_FRAMES = 3;
724+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
725+function startInsetEndSettleWatchdog(ctx) {
726+ const state = ctx.state;
727+ if (state.insetEndSettleWatchdogActive) {
728+ return;
729+ }
730+ state.insetEndSettleWatchdogActive = true;
731+ state.didUserDrag = false;
732+ // Hold the readyToRender opacity gate until the end landing is stable, so
733+ // the estimated-to-measured settle chase happens before first VISIBLE
734+ // paint instead of in front of the user. Capped so slow measurement can
735+ // never hide content for long.
736+ state.insetEndRevealHold = true;
737+ let frames = 0;
738+ let settledFrames = 0;
739+ let revealStableFrames = 0;
740+ const releaseRevealHold = () => {
741+ if (state.insetEndRevealHold) {
742+ state.insetEndRevealHold = false;
743+ setInitialRenderState(ctx, {});
744+ }
745+ };
746+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
747+ // can move the end target one more time — do that upgrade while still
748+ // hidden (phase 1), then require stability again before revealing (phase 2)
749+ // so the first visible frame is the settled one.
750+ let revealPhase = 1;
751+ const onRevealStability = () => {
752+ var _a3;
753+ if (!state.insetEndRevealHold) {
754+ return;
755+ }
756+ if (revealPhase === 1) {
757+ revealPhase = 2;
758+ revealStableFrames = 0;
759+ setAdaptiveRender(ctx, "normal");
760+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
761+ return;
762+ }
763+ releaseRevealHold();
764+ };
765+ const tick = () => {
766+ // The user owns the scroll from their first drag — a re-pin here would
767+ // pin the list under their finger.
768+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
769+ state.insetEndSettleWatchdogActive = false;
770+ releaseRevealHold();
771+ return;
772+ }
773+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
774+ releaseRevealHold();
775+ }
776+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
777+ const contentSize = getContentSize(ctx);
778+ const scrollLength = state.scrollLength;
779+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
780+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
781+ const distance = endOffset - state.scroll;
782+ // Estimated row sizes converging to measured ones can strand the initial
783+ // end landing when the library's own end-anchor bookkeeping gives up.
784+ // While still near the end (never fighting a user who scrolled away),
785+ // re-pin to the current true end until sizes stop changing.
786+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
787+ settledFrames = 0;
788+ revealStableFrames = 0;
789+ const scroller = state.refScroller.current;
790+ if (scroller) {
791+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
792+ }
793+ } else {
794+ settledFrames++;
795+ revealStableFrames++;
796+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
797+ onRevealStability();
798+ }
799+ }
800+ } else {
801+ // Conditions that make re-pinning unnecessary (underflow, in-flight
802+ // programmatic scroll) count toward stability for the reveal.
803+ revealStableFrames++;
804+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
805+ onRevealStability();
806+ }
807+ }
808+ requestAnimationFrame(tick);
809+ };
810+ requestAnimationFrame(tick);
811+}
812 function dispatchInitialScroll(ctx, params) {
813 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
814+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
815+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
816+ if (ctx.state.scrollLength <= 0) {
817+ // The scroll length is unknown, so the resolved target is meaningless;
818+ // the initial-scroll machinery re-advances after layout.
819+ return;
820+ }
821+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
822+ // Content underflows the viewport and nothing has scrolled yet: the
823+ // untouched native rest position (UIKit's adjustedContentInset) IS the
824+ // end position. Dispatching a negative scroll here would race UIKit's
825+ // inset application during screen attach, so just finish the session.
826+ finishInitialScroll(ctx, {
827+ resolvedOffset
828+ });
829+ return;
830+ }
831+ ctx.state.didDispatchInsetInitialScroll = true;
832+ if (target.viewPosition === 1) {
833+ startInsetEndSettleWatchdog(ctx);
834+ }
835+ }
836 const requestedIndex = target.index;
837 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
838 const itemSize = getItemSizeAtIndex(ctx, index);
839@@ -2683,7 +2828,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
840 return;
841 }
842 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
843- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
844+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
845+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
846+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
847 if (!shouldKeepEndTargetAlive) {
848 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
849 clearPendingInitialScrollFooterLayout(ctx, {
850@@ -4616,7 +4763,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
851 }
852 contentBelowAnchor += footerSize + stylePaddingBottom;
853 isReady = !hasUnknownTailSize;
854- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
855+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
856+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
857 } else if (anchorIndex >= 0) {
858 isReady = false;
859 }
860@@ -4634,6 +4782,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
861 updateScroll(ctx, state.scroll, true);
862 }
863 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
864+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
865+ set$(ctx, "anchoredEndSpaceSize", nextSize);
866+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
867+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
868+ updateScroll(ctx, state.scroll, true);
869+ }
870 }
871 return nextSize;
872 }
873@@ -6939,6 +7093,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
874 dataVersion,
875 drawDistance = 250,
876 contentInsetEndAdjustment,
877+ contentInsetStartAdjustment,
878 estimatedItemSize = 100,
879 estimatedListSize,
880 extraData,
881@@ -7055,7 +7210,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
882 const combinedRef = useCombinedRef(refScroller, refScrollView);
883 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
884 const stickyHeaderIndices = stickyHeaderIndicesProp;
885- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
886+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
887 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
888 const alwaysRenderIndices = useMemo(() => {
889 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
890@@ -7194,6 +7349,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
891 contentContainerAlignItems: contentContainerStyle.alignItems,
892 contentInset,
893 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
894+ contentInsetStartAdjustment,
895 data: dataProp,
896 dataKey,
897 dataVersion,
898@@ -7282,6 +7438,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
899 return void 0;
900 }
901 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
902+ if (getContentInsetStartAdjustment(ctx) > 0) {
903+ // With a native leading inset (transparent header + automatic insets),
904+ // any mount-time contentOffset races UIKit's adjustedContentInset
905+ // application. The gated initial-scroll dispatch positions the list once
906+ // real sizes are known instead.
907+ return void 0;
908+ }
909 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
910 }, [usesBootstrapInitialScroll]);
911 useLayoutEffect(() => {
912@@ -7505,6 +7668,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
913 onScroll: (event) => onScroll(ctx, event),
914 onScrollBeginDrag: (event) => {
915 var _a4, _b2;
916+ ctx.state.didUserDrag = true;
917 prepareReachedEdgeForNextUserScroll(ctx);
918 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
919 },
920@@ -7534,6 +7698,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
921 onLayout,
922 onLayoutFooter,
923 onMomentumScrollEnd: fns.onMomentumScrollEnd,
924+ onScrollBeginDrag: fns.onScrollBeginDrag,
925 onScroll: onScrollHandler,
926 recycleItems,
927 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
928diff --git a/reanimated.d.ts b/reanimated.d.ts
929index 940da28..28dccbe 100644
930--- a/reanimated.d.ts
931+++ b/reanimated.d.ts
932@@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
933 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
934 */
935 contentInsetEndAdjustment?: number;
936+ /**
937+ * Width/height of a leading content inset applied natively outside the list's knowledge
938+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
939+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
940+ */
941+ contentInsetStartAdjustment?: number;
942 /**
943 * Number of columns to render items in.
944 * @default 1
945diff --git a/reanimated.js b/reanimated.js
946index f1265fa..16dcef0 100644
947--- a/reanimated.js
948+++ b/reanimated.js
949@@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
950 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
951 const prevItemKeyRef = React__namespace.useRef(void 0);
952 let shouldSkipTransitionForRecycleReuse = false;
953- if (recycleItems && layoutTransition) {
954+ if (layoutTransition) {
955 const itemKeySignal = `containerItemKey${id}`;
956 const itemKey = peek$(ctx, itemKeySignal);
957 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
958@@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
959 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
960 [horizontal, positionValue, style]
961 );
962+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
963+ // which are compensated by an equal contentOffset shift so the row should
964+ // not visibly move — animating turns the invisible correction into a
965+ // lurch-and-settle; (2) any reposition while the user is actively
966+ // scrolling, where rows shifting under the finger reads as jank. The
967+ // transition exists to smooth at-rest shifts (streaming text, work-log
968+ // folds), so gate it to at-rest moments.
969+ const now = Date.now();
970+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 ||
971+ now - (ctx.state.lastNativeScrollTime || 0) < 300;
972 return /* @__PURE__ */ React__namespace.createElement(
973 Reanimated__default.default.View,
974 {
975- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
976+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,
977 ref: refView,
978 style: viewStyle,
979 ...rest
980diff --git a/reanimated.mjs b/reanimated.mjs
981index 29a00d5..9c25ec5 100644
982--- a/reanimated.mjs
983+++ b/reanimated.mjs
984@@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
985 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
986 const prevItemKeyRef = React.useRef(void 0);
987 let shouldSkipTransitionForRecycleReuse = false;
988- if (recycleItems && layoutTransition) {
989+ if (layoutTransition) {
990 const itemKeySignal = `containerItemKey${id}`;
991 const itemKey = peek$(ctx, itemKeySignal);
992 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
993@@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
994 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
995 [horizontal, positionValue, style]
996 );
997+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
998+ // which are compensated by an equal contentOffset shift so the row should
999+ // not visibly move — animating turns the invisible correction into a
1000+ // lurch-and-settle; (2) any reposition while the user is actively
1001+ // scrolling, where rows shifting under the finger reads as jank. The
1002+ // transition exists to smooth at-rest shifts (streaming text, work-log
1003+ // folds), so gate it to at-rest moments.
1004+ const now = Date.now();
1005+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 ||
1006+ now - (ctx.state.lastNativeScrollTime || 0) < 300;
1007 return /* @__PURE__ */ React.createElement(
1008 Reanimated.View,
1009 {
1010- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1011+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,
1012 ref: refView,
1013 style: viewStyle,
1014 ...rest
patches/@legendapp__list@3.3.5.patch created+1014
...@@ -0,0 +1,1014 @@
1diff --git a/keyboard.d.ts b/keyboard.d.ts
2index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 100644
3--- a/keyboard.d.ts
4+++ b/keyboard.d.ts
5@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = {
6 type KeyboardChatComposerRef = {
7 current: Pick<View, "measure"> | null;
8 };
9-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): {
10+declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): {
11 contentInsetEndAdjustment: SharedValue<number>;
12 onComposerLayout: (event: LayoutChangeEvent) => void;
13 };
14@@ -288,8 +288,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb
15 scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>;
16 };
17 declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & {
18+ adjustedInsetCompensation?: number;
19 anchoredEndSpace?: AnchoredEndSpaceConfig;
20 contentInsetEndAdjustment?: SharedValue<number>;
21+ contentInsetEndStaticAdjustment?: number;
22 keyboardOffset?: number;
23 } & React.RefAttributes<LegendListRef>) => React.ReactElement | null;
24
25diff --git a/keyboard.js b/keyboard.js
26index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 100644
27--- a/keyboard.js
28+++ b/keyboard.js
29@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.
30 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
31 );
32 }
33-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
34+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {
35 const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight);
36 const lastHeightRef = React.useRef(void 0);
37 const reportHeight = React.useCallback(
38- (height) => {
39+ (rawHeight) => {
40+ const height = Math.max(0, rawHeight + heightAdjustment);
41 var _a;
42 if (Number.isFinite(height) && height !== lastHeightRef.current) {
43 lastHeightRef.current = height;
44 contentInsetEndAdjustment.value = height;
45- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
46 }
47 },
48- [contentInsetEndAdjustment, listRef]
49+ [contentInsetEndAdjustment, heightAdjustment, listRef]
50 );
51 React.useLayoutEffect(() => {
52 var _a;
53@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
54 }
55 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
56 const {
57+ adjustedInsetCompensation,
58 anchoredEndSpace,
59 applyWorkaroundForContentInsetHitTestBug,
60 contentInsetEndAdjustment,
61+ contentInsetEndStaticAdjustment,
62 freeze,
63 keyboardLiftBehavior,
64 keyboardOffset,
65@@ -108,11 +110,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
66 ...anchoredEndSpace,
67 onSizeChanged: (size) => {
68 var _a;
69- blankSpace.value = size;
70+ // The anchored blank is sized against the ADJUSTED viewport, but the
71+ // scroll component writes it as a raw contentInset that UIKit tops up
72+ // with the safe-area bottom — write it net of that or the end rest
73+ // (and the anchored message) sinks one safe-area under the header.
74+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
75 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
76 }
77 };
78- }, [anchoredEndSpace, blankSpace]);
79+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
80 const onContentInsetChange = React.useCallback((insets) => {
81 var _a;
82 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
83@@ -123,6 +129,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
84 reactNativeKeyboardController.KeyboardChatScrollView,
85 {
86 ...scrollProps,
87+ adjustedInsetCompensation,
88 applyWorkaroundForContentInsetHitTestBug,
89 blankSpace,
90 extraContentPadding: contentInsetEndAdjustment,
91@@ -134,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
92 );
93 },
94 [
95+ adjustedInsetCompensation,
96 applyWorkaroundForContentInsetHitTestBug,
97 blankSpace,
98 contentInsetEndAdjustment,
99@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
100 {
101 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
102 anchoredEndSpaceOwnerInternal: "scroll",
103+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
104 ref: combinedRef,
105 renderScrollComponent: memoList,
106 ...rest
107diff --git a/keyboard.mjs b/keyboard.mjs
108index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644
109--- a/keyboard.mjs
110+++ b/keyboard.mjs
111@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {
112 "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
113 );
114 }
115-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
116+function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {
117 const contentInsetEndAdjustment = useSharedValue(initialHeight);
118 const lastHeightRef = useRef(void 0);
119 const reportHeight = useCallback(
120- (height) => {
121+ (rawHeight) => {
122+ const height = Math.max(0, rawHeight + heightAdjustment);
123 var _a;
124 if (Number.isFinite(height) && height !== lastHeightRef.current) {
125 lastHeightRef.current = height;
126 contentInsetEndAdjustment.value = height;
127- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
128 }
129 },
130- [contentInsetEndAdjustment, listRef]
131+ [contentInsetEndAdjustment, heightAdjustment, listRef]
132 );
133 useLayoutEffect(() => {
134 var _a;
135@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
136 }
137 var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
138 const {
139+ adjustedInsetCompensation,
140 anchoredEndSpace,
141 applyWorkaroundForContentInsetHitTestBug,
142 contentInsetEndAdjustment,
143+ contentInsetEndStaticAdjustment,
144 freeze,
145 keyboardLiftBehavior,
146 keyboardOffset,
147@@ -87,11 +89,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
148 ...anchoredEndSpace,
149 onSizeChanged: (size) => {
150 var _a;
151- blankSpace.value = size;
152+ // The anchored blank is sized against the ADJUSTED viewport, but the
153+ // scroll component writes it as a raw contentInset that UIKit tops up
154+ // with the safe-area bottom — write it net of that or the end rest
155+ // (and the anchored message) sinks one safe-area under the header.
156+ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
157 (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
158 }
159 };
160- }, [anchoredEndSpace, blankSpace]);
161+ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
162 const onContentInsetChange = useCallback((insets) => {
163 var _a;
164 (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
165@@ -102,6 +108,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
166 KeyboardChatScrollView,
167 {
168 ...scrollProps,
169+ adjustedInsetCompensation,
170 applyWorkaroundForContentInsetHitTestBug,
171 blankSpace,
172 extraContentPadding: contentInsetEndAdjustment,
173@@ -113,6 +120,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
174 );
175 },
176 [
177+ adjustedInsetCompensation,
178 applyWorkaroundForContentInsetHitTestBug,
179 blankSpace,
180 contentInsetEndAdjustment,
181@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
182 {
183 anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
184 anchoredEndSpaceOwnerInternal: "scroll",
185+ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
186 ref: combinedRef,
187 renderScrollComponent: memoList,
188 ...rest
189diff --git a/react-native.d.ts b/react-native.d.ts
190index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644
191--- a/react-native.d.ts
192+++ b/react-native.d.ts
193@@ -293,6 +293,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
194 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
195 */
196 contentInsetEndAdjustment?: number;
197+ /**
198+ * Width/height of a leading content inset applied natively outside the list's knowledge
199+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
200+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
201+ */
202+ contentInsetStartAdjustment?: number;
203 /**
204 * Number of columns to render items in.
205 * @default 1
206diff --git a/react-native.js b/react-native.js
207index b3c5a306b293f797a8b338adfca3060c0f6db22b..89077be4d6833cfaabf9d6d6205d9551505e32b4 100644
208--- a/react-native.js
209+++ b/react-native.js
210@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, {
211 if (didInitialScroll) {
212 state.didFinishInitialScroll = true;
213 }
214- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
215+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
216 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
217 set$(ctx, "readyToRender", true);
218 setAdaptiveRender(ctx, "normal", "ready");
219@@ -1304,18 +1304,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
220 }
221
222 // src/core/clampScrollOffset.ts
223+function getContentInsetStartAdjustment(ctx) {
224+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
225+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
226+}
227 function clampScrollOffset(ctx, offset, scrollTarget) {
228 const state = ctx.state;
229 const contentSize = getContentSize(ctx);
230+ const minOffset = -getContentInsetStartAdjustment(ctx);
231 let clampedOffset = offset;
232 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
233- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
234+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
235 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
236 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
237 const maxOffset = baseMaxOffset + extraEndOffset;
238 clampedOffset = Math.min(offset, maxOffset);
239 }
240- clampedOffset = Math.max(0, clampedOffset);
241+ clampedOffset = Math.max(minOffset, clampedOffset);
242 return clampedOffset;
243 }
244
245@@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) {
246 finishScrollTo(ctx);
247 }
248 }
249-function scrollToFallbackOffset(ctx, offset) {
250+function scrollToFallbackOffset(ctx, offset, animated) {
251 var _a3;
252 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
253- animated: false,
254+ animated: !!animated,
255 x: ctx.state.props.horizontal ? offset : 0,
256 y: ctx.state.props.horizontal ? 0 : offset
257 });
258@@ -1503,7 +1508,10 @@ function checkFinishedScrollFallback(ctx) {
259 );
260 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
261 } else if (shouldRetryUnalignedEndScroll) {
262- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
263+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
264+ if (!isActivelyAnimatingToEnd) {
265+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
266+ }
267 scheduleFallbackCheck(100);
268 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
269 finishScrollTo(ctx);
270@@ -1566,9 +1574,18 @@ function doMaintainScrollAtEnd(ctx) {
271 }
272 if (shouldMaintainScrollAtEnd) {
273 state.pendingMaintainScrollAtEnd = false;
274+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
275+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
276+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
277+ return false;
278+ }
279 const contentSize = getContentSize(ctx);
280+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
281 if (contentSize < state.scrollLength) {
282- state.scroll = 0;
283+ state.scroll = -maintainInsetStartAdjustment;
284+ if (maintainInsetStartAdjustment > 0) {
285+ return true;
286+ }
287 }
288 if (!state.maintainingScrollAtEnd) {
289 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
290@@ -1591,9 +1608,18 @@ function doMaintainScrollAtEnd(ctx) {
291 y: 0
292 });
293 } else {
294- scroller == null ? void 0 : scroller.scrollToEnd({
295- animated: maintainScrollAtEnd.animated
296- });
297+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
298+ if (insetStartAdjustment > 0) {
299+ scroller == null ? void 0 : scroller.scrollTo({
300+ animated: maintainScrollAtEnd.animated,
301+ x: 0,
302+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
303+ });
304+ } else {
305+ scroller == null ? void 0 : scroller.scrollToEnd({
306+ animated: maintainScrollAtEnd.animated
307+ });
308+ }
309 }
310 setTimeout(
311 () => {
312@@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) {
313 function requestAdjust(ctx, positionDiff, dataChanged) {
314 const state = ctx.state;
315 if (Math.abs(positionDiff) > 0.1) {
316+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
317+ // size adjustment are already compensated by a contentOffset shift, so
318+ // animating them would make rows visibly lurch and slide back.
319+ state.lastMVCPAdjustTime = Date.now();
320 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
321 const doit = () => {
322 if (needsScrollWorkaround) {
323@@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
324 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
325 return 0;
326 }
327- const maxScroll = Math.max(0, totalSize - state.scrollLength);
328+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
329+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
330+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
331 const clampDelta = maxScroll - state.scroll;
332 if (unresolvedAmount < 0) {
333 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
334@@ -1790,7 +1822,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
335 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
336 return true;
337 }
338- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
339+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
340 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
341 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
342 if (isAtExpectedNativeClamp) {
343@@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) {
344 if (diff > 0) {
345 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
346 } else {
347- const maxScroll = Math.max(0, totalSize - state.scrollLength);
348+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
349 state.scroll = maxScroll;
350 state.scrollPending = maxScroll;
351 diff = 0;
352@@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, {
353 }
354
355 // src/core/initialScroll.ts
356+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
357+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
358+var INSET_END_REVEAL_STABLE_FRAMES = 3;
359+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
360+function startInsetEndSettleWatchdog(ctx) {
361+ const state = ctx.state;
362+ if (state.insetEndSettleWatchdogActive) {
363+ return;
364+ }
365+ state.insetEndSettleWatchdogActive = true;
366+ state.didUserDrag = false;
367+ // Hold the readyToRender opacity gate until the end landing is stable, so
368+ // the estimated-to-measured settle chase happens before first VISIBLE
369+ // paint instead of in front of the user. Capped so slow measurement can
370+ // never hide content for long.
371+ state.insetEndRevealHold = true;
372+ let frames = 0;
373+ let settledFrames = 0;
374+ let revealStableFrames = 0;
375+ const releaseRevealHold = () => {
376+ if (state.insetEndRevealHold) {
377+ state.insetEndRevealHold = false;
378+ setInitialRenderState(ctx, {});
379+ }
380+ };
381+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
382+ // can move the end target one more time — do that upgrade while still
383+ // hidden (phase 1), then require stability again before revealing (phase 2)
384+ // so the first visible frame is the settled one.
385+ let revealPhase = 1;
386+ const onRevealStability = () => {
387+ var _a3;
388+ if (!state.insetEndRevealHold) {
389+ return;
390+ }
391+ if (revealPhase === 1) {
392+ revealPhase = 2;
393+ revealStableFrames = 0;
394+ setAdaptiveRender(ctx, "normal");
395+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
396+ return;
397+ }
398+ releaseRevealHold();
399+ };
400+ const tick = () => {
401+ // The user owns the scroll from their first drag — a re-pin here would
402+ // pin the list under their finger.
403+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
404+ state.insetEndSettleWatchdogActive = false;
405+ releaseRevealHold();
406+ return;
407+ }
408+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
409+ releaseRevealHold();
410+ }
411+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
412+ const contentSize = getContentSize(ctx);
413+ const scrollLength = state.scrollLength;
414+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
415+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
416+ const distance = endOffset - state.scroll;
417+ // Estimated row sizes converging to measured ones can strand the initial
418+ // end landing when the library's own end-anchor bookkeeping gives up.
419+ // While still near the end (never fighting a user who scrolled away),
420+ // re-pin to the current true end until sizes stop changing.
421+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
422+ settledFrames = 0;
423+ revealStableFrames = 0;
424+ const scroller = state.refScroller.current;
425+ if (scroller) {
426+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
427+ }
428+ } else {
429+ settledFrames++;
430+ revealStableFrames++;
431+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
432+ onRevealStability();
433+ }
434+ }
435+ } else {
436+ // Conditions that make re-pinning unnecessary (underflow, in-flight
437+ // programmatic scroll) count toward stability for the reveal.
438+ revealStableFrames++;
439+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
440+ onRevealStability();
441+ }
442+ }
443+ requestAnimationFrame(tick);
444+ };
445+ requestAnimationFrame(tick);
446+}
447 function dispatchInitialScroll(ctx, params) {
448 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
449+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
450+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
451+ if (ctx.state.scrollLength <= 0) {
452+ // The scroll length is unknown, so the resolved target is meaningless;
453+ // the initial-scroll machinery re-advances after layout.
454+ return;
455+ }
456+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
457+ // Content underflows the viewport and nothing has scrolled yet: the
458+ // untouched native rest position (UIKit's adjustedContentInset) IS the
459+ // end position. Dispatching a negative scroll here would race UIKit's
460+ // inset application during screen attach, so just finish the session.
461+ finishInitialScroll(ctx, {
462+ resolvedOffset
463+ });
464+ return;
465+ }
466+ ctx.state.didDispatchInsetInitialScroll = true;
467+ if (target.viewPosition === 1) {
468+ startInsetEndSettleWatchdog(ctx);
469+ }
470+ }
471 const requestedIndex = target.index;
472 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
473 const itemSize = getItemSizeAtIndex(ctx, index);
474@@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
475 return;
476 }
477 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
478- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
479+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
480+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
481+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
482 if (!shouldKeepEndTargetAlive) {
483 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
484 clearPendingInitialScrollFooterLayout(ctx, {
485@@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
486 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
487 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
488 isReady = !hasUnknownTailSize;
489- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
490+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
491+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
492 } else if (anchorIndex >= 0) {
493 isReady = false;
494 }
495@@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
496 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
497 }
498 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
499+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
500+ set$(ctx, "anchoredEndSpaceSize", nextSize);
501+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
502+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
503+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
504+ }
505 }
506 return nextSize;
507 }
508@@ -7075,6 +7229,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
509 dataVersion,
510 drawDistance = 250,
511 contentInsetEndAdjustment,
512+ contentInsetStartAdjustment,
513 estimatedItemSize = 100,
514 estimatedListSize,
515 extraData,
516@@ -7105,6 +7260,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
517 onLayout: onLayoutProp,
518 onLoad,
519 onMomentumScrollEnd,
520+ onScrollBeginDrag,
521 onRefresh,
522 onScroll: onScrollProp,
523 onScrollBeginDrag,
524@@ -7200,7 +7356,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
525 const combinedRef = useCombinedRef(refScroller, refScrollView);
526 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
527 const stickyHeaderIndices = stickyHeaderIndicesProp;
528- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
529+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
530 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved);
531 const alwaysRenderIndices = React2.useMemo(() => {
532 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
533@@ -7341,6 +7497,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
534 contentContainerAlignItems: contentContainerStyle.alignItems,
535 contentInset,
536 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
537+ contentInsetStartAdjustment,
538 data: dataProp,
539 dataKey,
540 dataVersion,
541@@ -7423,6 +7580,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
542 return void 0;
543 }
544 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
545+ if (getContentInsetStartAdjustment(ctx) > 0) {
546+ // With a native leading inset (transparent header + automatic insets),
547+ // any mount-time contentOffset races UIKit's adjustedContentInset
548+ // application. The gated initial-scroll dispatch positions the list once
549+ // real sizes are known instead.
550+ return void 0;
551+ }
552 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
553 }, [usesBootstrapInitialScroll]);
554 React2.useLayoutEffect(() => {
555@@ -7651,6 +7815,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
556 onScroll: (event) => onScroll(ctx, event),
557 onScrollBeginDrag: (event) => {
558 var _a4, _b2;
559+ ctx.state.didUserDrag = true;
560 prepareReachedEdgeForNextUserScroll(ctx);
561 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
562 },
563@@ -7681,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
564 onLayout,
565 onLayoutFooter,
566 onMomentumScrollEnd: fns.onMomentumScrollEnd,
567+ onScrollBeginDrag: fns.onScrollBeginDrag,
568 onScroll: onScrollHandler,
569 recycleItems,
570 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
571diff --git a/react-native.mjs b/react-native.mjs
572index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644
573--- a/react-native.mjs
574+++ b/react-native.mjs
575@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, {
576 if (didInitialScroll) {
577 state.didFinishInitialScroll = true;
578 }
579- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll);
580+ const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold);
581 if (isReadyToRender && !peek$(ctx, "readyToRender")) {
582 set$(ctx, "readyToRender", true);
583 setAdaptiveRender(ctx, "normal", "ready");
584@@ -1283,18 +1283,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
585 }
586
587 // src/core/clampScrollOffset.ts
588+function getContentInsetStartAdjustment(ctx) {
589+ const adjustment = ctx.state.props.contentInsetStartAdjustment;
590+ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
591+}
592 function clampScrollOffset(ctx, offset, scrollTarget) {
593 const state = ctx.state;
594 const contentSize = getContentSize(ctx);
595+ const minOffset = -getContentInsetStartAdjustment(ctx);
596 let clampedOffset = offset;
597 if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
598- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
599+ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
600 const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
601 const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
602 const maxOffset = baseMaxOffset + extraEndOffset;
603 clampedOffset = Math.min(offset, maxOffset);
604 }
605- clampedOffset = Math.max(0, clampedOffset);
606+ clampedOffset = Math.max(minOffset, clampedOffset);
607 return clampedOffset;
608 }
609
610@@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) {
611 finishScrollTo(ctx);
612 }
613 }
614-function scrollToFallbackOffset(ctx, offset) {
615+function scrollToFallbackOffset(ctx, offset, animated) {
616 var _a3;
617 (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
618- animated: false,
619+ animated: !!animated,
620 x: ctx.state.props.horizontal ? offset : 0,
621 y: ctx.state.props.horizontal ? 0 : offset
622 });
623@@ -1482,7 +1487,10 @@ function checkFinishedScrollFallback(ctx) {
624 );
625 scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
626 } else if (shouldRetryUnalignedEndScroll) {
627- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
628+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
629+ if (!isActivelyAnimatingToEnd) {
630+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
631+ }
632 scheduleFallbackCheck(100);
633 } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
634 finishScrollTo(ctx);
635@@ -1545,9 +1553,18 @@ function doMaintainScrollAtEnd(ctx) {
636 }
637 if (shouldMaintainScrollAtEnd) {
638 state.pendingMaintainScrollAtEnd = false;
639+ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
640+ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
641+ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
642+ return false;
643+ }
644 const contentSize = getContentSize(ctx);
645+ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
646 if (contentSize < state.scrollLength) {
647- state.scroll = 0;
648+ state.scroll = -maintainInsetStartAdjustment;
649+ if (maintainInsetStartAdjustment > 0) {
650+ return true;
651+ }
652 }
653 if (!state.maintainingScrollAtEnd) {
654 const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
655@@ -1570,9 +1587,18 @@ function doMaintainScrollAtEnd(ctx) {
656 y: 0
657 });
658 } else {
659- scroller == null ? void 0 : scroller.scrollToEnd({
660- animated: maintainScrollAtEnd.animated
661- });
662+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
663+ if (insetStartAdjustment > 0) {
664+ scroller == null ? void 0 : scroller.scrollTo({
665+ animated: maintainScrollAtEnd.animated,
666+ x: 0,
667+ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
668+ });
669+ } else {
670+ scroller == null ? void 0 : scroller.scrollToEnd({
671+ animated: maintainScrollAtEnd.animated
672+ });
673+ }
674 }
675 setTimeout(
676 () => {
677@@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) {
678 function requestAdjust(ctx, positionDiff, dataChanged) {
679 const state = ctx.state;
680 if (Math.abs(positionDiff) > 0.1) {
681+ // Timestamp for ReanimatedPositionView: repositions caused by an MVCP
682+ // size adjustment are already compensated by a contentOffset shift, so
683+ // animating them would make rows visibly lurch and slide back.
684+ state.lastMVCPAdjustTime = Date.now();
685 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff;
686 const doit = () => {
687 if (needsScrollWorkaround) {
688@@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
689 if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
690 return 0;
691 }
692- const maxScroll = Math.max(0, totalSize - state.scrollLength);
693+ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
694+ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
695+ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
696 const clampDelta = maxScroll - state.scroll;
697 if (unresolvedAmount < 0) {
698 return Math.max(unresolvedAmount, Math.min(0, clampDelta));
699@@ -1769,7 +1801,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
700 settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
701 return true;
702 }
703- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
704+ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
705 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
706 const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
707 if (isAtExpectedNativeClamp) {
708@@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) {
709 if (diff > 0) {
710 diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
711 } else {
712- const maxScroll = Math.max(0, totalSize - state.scrollLength);
713+ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
714 state.scroll = maxScroll;
715 state.scrollPending = maxScroll;
716 diff = 0;
717@@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, {
718 }
719
720 // src/core/initialScroll.ts
721+var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
722+var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
723+var INSET_END_REVEAL_STABLE_FRAMES = 3;
724+var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40;
725+function startInsetEndSettleWatchdog(ctx) {
726+ const state = ctx.state;
727+ if (state.insetEndSettleWatchdogActive) {
728+ return;
729+ }
730+ state.insetEndSettleWatchdogActive = true;
731+ state.didUserDrag = false;
732+ // Hold the readyToRender opacity gate until the end landing is stable, so
733+ // the estimated-to-measured settle chase happens before first VISIBLE
734+ // paint instead of in front of the user. Capped so slow measurement can
735+ // never hide content for long.
736+ state.insetEndRevealHold = true;
737+ let frames = 0;
738+ let settledFrames = 0;
739+ let revealStableFrames = 0;
740+ const releaseRevealHold = () => {
741+ if (state.insetEndRevealHold) {
742+ state.insetEndRevealHold = false;
743+ setInitialRenderState(ctx, {});
744+ }
745+ };
746+ // Revealing flips adaptive rendering to "normal", which re-renders rows and
747+ // can move the end target one more time — do that upgrade while still
748+ // hidden (phase 1), then require stability again before revealing (phase 2)
749+ // so the first visible frame is the settled one.
750+ let revealPhase = 1;
751+ const onRevealStability = () => {
752+ var _a3;
753+ if (!state.insetEndRevealHold) {
754+ return;
755+ }
756+ if (revealPhase === 1) {
757+ revealPhase = 2;
758+ revealStableFrames = 0;
759+ setAdaptiveRender(ctx, "normal");
760+ (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state);
761+ return;
762+ }
763+ releaseRevealHold();
764+ };
765+ const tick = () => {
766+ // The user owns the scroll from their first drag — a re-pin here would
767+ // pin the list under their finger.
768+ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
769+ state.insetEndSettleWatchdogActive = false;
770+ releaseRevealHold();
771+ return;
772+ }
773+ if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) {
774+ releaseRevealHold();
775+ }
776+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
777+ const contentSize = getContentSize(ctx);
778+ const scrollLength = state.scrollLength;
779+ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
780+ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
781+ const distance = endOffset - state.scroll;
782+ // Estimated row sizes converging to measured ones can strand the initial
783+ // end landing when the library's own end-anchor bookkeeping gives up.
784+ // While still near the end (never fighting a user who scrolled away),
785+ // re-pin to the current true end until sizes stop changing.
786+ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
787+ settledFrames = 0;
788+ revealStableFrames = 0;
789+ const scroller = state.refScroller.current;
790+ if (scroller) {
791+ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
792+ }
793+ } else {
794+ settledFrames++;
795+ revealStableFrames++;
796+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
797+ onRevealStability();
798+ }
799+ }
800+ } else {
801+ // Conditions that make re-pinning unnecessary (underflow, in-flight
802+ // programmatic scroll) count toward stability for the reveal.
803+ revealStableFrames++;
804+ if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) {
805+ onRevealStability();
806+ }
807+ }
808+ requestAnimationFrame(tick);
809+ };
810+ requestAnimationFrame(tick);
811+}
812 function dispatchInitialScroll(ctx, params) {
813 const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
814+ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
815+ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
816+ if (ctx.state.scrollLength <= 0) {
817+ // The scroll length is unknown, so the resolved target is meaningless;
818+ // the initial-scroll machinery re-advances after layout.
819+ return;
820+ }
821+ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
822+ // Content underflows the viewport and nothing has scrolled yet: the
823+ // untouched native rest position (UIKit's adjustedContentInset) IS the
824+ // end position. Dispatching a negative scroll here would race UIKit's
825+ // inset application during screen attach, so just finish the session.
826+ finishInitialScroll(ctx, {
827+ resolvedOffset
828+ });
829+ return;
830+ }
831+ ctx.state.didDispatchInsetInitialScroll = true;
832+ if (target.viewPosition === 1) {
833+ startInsetEndSettleWatchdog(ctx);
834+ }
835+ }
836 const requestedIndex = target.index;
837 const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
838 const itemSize = getItemSizeAtIndex(ctx, index);
839@@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
840 return;
841 }
842 if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
843- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
844+ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
845+ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
846+ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
847 if (!shouldKeepEndTargetAlive) {
848 if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
849 clearPendingInitialScrollFooterLayout(ctx, {
850@@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
851 contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap);
852 contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props);
853 isReady = !hasUnknownTailSize;
854- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
855+ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
856+ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
857 } else if (anchorIndex >= 0) {
858 isReady = false;
859 }
860@@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
861 updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
862 }
863 (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
864+ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
865+ set$(ctx, "anchoredEndSpaceSize", nextSize);
866+ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
867+ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
868+ updateScroll(ctx, state.scroll, true, { markHasScrolled: false });
869+ }
870 }
871 return nextSize;
872 }
873@@ -7054,6 +7208,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
874 dataVersion,
875 drawDistance = 250,
876 contentInsetEndAdjustment,
877+ contentInsetStartAdjustment,
878 estimatedItemSize = 100,
879 estimatedListSize,
880 extraData,
881@@ -7179,7 +7334,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
882 const combinedRef = useCombinedRef(refScroller, refScrollView);
883 const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString());
884 const stickyHeaderIndices = stickyHeaderIndicesProp;
885- const contentInsetEndAdjustmentResolved = Platform.OS === "web" ? contentInsetEndAdjustment : void 0;
886+ const contentInsetEndAdjustmentResolved = contentInsetEndAdjustment;
887 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved);
888 const alwaysRenderIndices = useMemo(() => {
889 const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex);
890@@ -7320,6 +7475,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
891 contentContainerAlignItems: contentContainerStyle.alignItems,
892 contentInset,
893 contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
894+ contentInsetStartAdjustment,
895 data: dataProp,
896 dataKey,
897 dataVersion,
898@@ -7402,6 +7558,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
899 return void 0;
900 }
901 const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
902+ if (getContentInsetStartAdjustment(ctx) > 0) {
903+ // With a native leading inset (transparent header + automatic insets),
904+ // any mount-time contentOffset races UIKit's adjustedContentInset
905+ // application. The gated initial-scroll dispatch positions the list once
906+ // real sizes are known instead.
907+ return void 0;
908+ }
909 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
910 }, [usesBootstrapInitialScroll]);
911 useLayoutEffect(() => {
912@@ -7630,6 +7793,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
913 onScroll: (event) => onScroll(ctx, event),
914 onScrollBeginDrag: (event) => {
915 var _a4, _b2;
916+ ctx.state.didUserDrag = true;
917 prepareReachedEdgeForNextUserScroll(ctx);
918 (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event);
919 },
920@@ -7660,6 +7824,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
921 onLayout,
922 onLayoutFooter,
923 onMomentumScrollEnd: fns.onMomentumScrollEnd,
924+ onScrollBeginDrag: fns.onScrollBeginDrag,
925 onScroll: onScrollHandler,
926 recycleItems,
927 refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
928diff --git a/reanimated.d.ts b/reanimated.d.ts
929index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 100644
930--- a/reanimated.d.ts
931+++ b/reanimated.d.ts
932@@ -294,6 +294,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
933 * The adjustment is also rendered as real content padding so the browser scroll range includes it.
934 */
935 contentInsetEndAdjustment?: number;
936+ /**
937+ * Width/height of a leading content inset applied natively outside the list's knowledge
938+ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
939+ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
940+ */
941+ contentInsetStartAdjustment?: number;
942 /**
943 * Number of columns to render items in.
944 * @default 1
945diff --git a/reanimated.js b/reanimated.js
946index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 100644
947--- a/reanimated.js
948+++ b/reanimated.js
949@@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
950 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
951 const prevItemKeyRef = React__namespace.useRef(void 0);
952 let shouldSkipTransitionForRecycleReuse = false;
953- if (recycleItems && layoutTransition) {
954+ if (layoutTransition) {
955 const itemKeySignal = `containerItemKey${id}`;
956 const itemKey = peek$(ctx, itemKeySignal);
957 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
958@@ -130,10 +130,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
959 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
960 [horizontal, positionValue, style]
961 );
962+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
963+ // which are compensated by an equal contentOffset shift so the row should
964+ // not visibly move — animating turns the invisible correction into a
965+ // lurch-and-settle; (2) any reposition while the user is actively
966+ // scrolling, where rows shifting under the finger reads as jank. The
967+ // transition exists to smooth at-rest shifts (streaming text, work-log
968+ // folds), so gate it to at-rest moments.
969+ const now = Date.now();
970+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 ||
971+ now - (ctx.state.lastNativeScrollTime || 0) < 300;
972 return /* @__PURE__ */ React__namespace.createElement(
973 Reanimated__default.default.View,
974 {
975- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
976+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,
977 ref: refView,
978 style: viewStyle,
979 ...rest
980diff --git a/reanimated.mjs b/reanimated.mjs
981index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 100644
982--- a/reanimated.mjs
983+++ b/reanimated.mjs
984@@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
985 const [positionValue = POSITION_OUT_OF_VIEW] = useArr$([`containerPosition${id}`]);
986 const prevItemKeyRef = React.useRef(void 0);
987 let shouldSkipTransitionForRecycleReuse = false;
988- if (recycleItems && layoutTransition) {
989+ if (layoutTransition) {
990 const itemKeySignal = `containerItemKey${id}`;
991 const itemKey = peek$(ctx, itemKeySignal);
992 shouldSkipTransitionForRecycleReuse = itemKey !== void 0 && prevItemKeyRef.current !== void 0 && prevItemKeyRef.current !== itemKey;
993@@ -106,10 +106,20 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent(
994 () => [style, horizontal ? { left: positionValue } : { top: positionValue }],
995 [horizontal, positionValue, style]
996 );
997+ // Two kinds of repositions must not animate: (1) MVCP size adjustments,
998+ // which are compensated by an equal contentOffset shift so the row should
999+ // not visibly move — animating turns the invisible correction into a
1000+ // lurch-and-settle; (2) any reposition while the user is actively
1001+ // scrolling, where rows shifting under the finger reads as jank. The
1002+ // transition exists to smooth at-rest shifts (streaming text, work-log
1003+ // folds), so gate it to at-rest moments.
1004+ const now = Date.now();
1005+ const isMVCPReposition = now - (ctx.state.lastMVCPAdjustTime || 0) < 300 ||
1006+ now - (ctx.state.lastNativeScrollTime || 0) < 300;
1007 return /* @__PURE__ */ React.createElement(
1008 Reanimated.View,
1009 {
1010- layout: shouldSkipTransitionForRecycleReuse ? void 0 : layoutTransition,
1011+ layout: shouldSkipTransitionForRecycleReuse || isMVCPReposition ? void 0 : layoutTransition,
1012 ref: refView,
1013 style: viewStyle,
1014 ...rest
patches/@react-native-menu__menu@2.0.0.patch+225-2
...@@ -1,9 +1,117 @@...@@ -1,9 +1,117 @@
1diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm
2index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644
3--- a/ios/NewArch/MenuView.mm
4+++ b/ios/NewArch/MenuView.mm
5@@ -105,6 +105,27 @@ - (void)onOpenMenu {
6 NSMutableArray<NSDictionary *> *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()];
7 if (action.subactions.size() > 0) {
8 for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) {
9+ NSMutableArray<NSDictionary *> *subSubactionsArray =
10+ [NSMutableArray arrayWithCapacity:subaction.subactions.size()];
11+ for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) {
12+ NSDictionary *subSubactionDict = @{
13+ @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()],
14+ @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()],
15+ @"titleColor": @(subSubaction.titleColor),
16+ @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()],
17+ @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()],
18+ @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()],
19+ @"imageColor": @(subSubaction.imageColor),
20+ @"displayInline": @(subSubaction.displayInline),
21+ @"attributes": @{
22+ @"destructive": @(subSubaction.attributes.destructive),
23+ @"disabled": @(subSubaction.attributes.disabled),
24+ @"hidden": @(subSubaction.attributes.hidden),
25+ @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented),
26+ },
27+ };
28+ [subSubactionsArray addObject:subSubactionDict];
29+ }
30 NSDictionary *subactionDict = @{
31 @"id": [NSString stringWithUTF8String:subaction.id.c_str()],
32 @"title": [NSString stringWithUTF8String:subaction.title.c_str()],
33@@ -118,7 +139,9 @@ - (void)onOpenMenu {
34 @"destructive": @(subaction.attributes.destructive),
35 @"disabled": @(subaction.attributes.disabled),
36 @"hidden": @(subaction.attributes.hidden),
37+ @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented),
38 },
39+ @"subactions": subSubactionsArray,
40 };
41 [subactionsArray addObject:subactionDict];
42 }
43@@ -138,6 +161,7 @@ - (void)onOpenMenu {
44 @"destructive": @(action.attributes.destructive),
45 @"disabled": @(action.attributes.disabled),
46 @"hidden": @(action.attributes.hidden),
47+ @"keepsMenuPresented": @(action.attributes.keepsMenuPresented),
48 },
49 @"subactions": subactionsArray,
50 };
1diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift51diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift
2index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 10064452index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644
3--- a/ios/Shared/MenuViewImplementation.swift53--- a/ios/Shared/MenuViewImplementation.swift
4+++ b/ios/Shared/MenuViewImplementation.swift54+++ b/ios/Shared/MenuViewImplementation.swift
5@@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton {55@@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton {
56 self.setup()
57 }
58
59+ // Presentation is tracked from the two delegate methods the class already
60+ // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping)
61+ // shadows UIButton's own implementation and degrades the button-anchored
62+ // presentation into generic context-menu chrome — an empty header row with
63+ // a dismiss chevron appears above the actions.
64 public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
65+ // Flush updates deferred by the presented-guard before the action
66+ // provider snapshots self.menu (covers a stuck flag from an
67+ // interaction that never ended cleanly).
68+ if pendingMenu != nil {
69+ pendingMenu = nil
70+ isMenuPresented = false
71+ self.setup()
72+ }
73+ isMenuPresented = true
74 sendMenuOpen()
75 return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
76 guard let self = self else { return nil }
77 return self.menu
78 }
79 }
80-
81+
82 public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
83 sendMenuClose()
84+ isMenuPresented = false
85+ if pendingMenu != nil {
86+ // Re-run the full assignment now that presentation is over, so
87+ // any props deferred by the presented-guard (menu contents, press
88+ // mode) land on the button.
89+ pendingMenu = nil
90+ self.setup()
91+ }
92 }
6 93
94+ private var isMenuPresented = false
95+ private var pendingMenu: UIMenu?
96+
97 func setup () {
98 let menu = UIMenu(title: _title,
99 identifier: nil,
100@@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton {
101 }
102 }
103
104+ if isMenuPresented {
105+ // An action fired with keepsMenuPresented leaves the menu on
106+ // screen, and reassigning self.menu while it is presented makes
107+ // UIKit dismiss it — the update triggered by a selection would
108+ // defeat the keep-presented attribute. Update the visible copy in
109+ // place instead and defer the button assignment to dismissal.
110+ pendingMenu = menu
111+ self.refreshPresentedMenu(menu)
112+ return
113+ }
114+
7 self.menu = menu115 self.menu = menu
8 self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress116 self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress
9+ // In long-press mode the button must not intercept touches: as a117+ // In long-press mode the button must not intercept touches: as a
...@@ -17,6 +125,50 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7...@@ -17,6 +125,50 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7
17+ self.updateLongPressInteraction()125+ self.updateLongPressInteraction()
18+ }126+ }
19+127+
128+ private func refreshPresentedMenu(_ menu: UIMenu) {
129+ // Tap mode presents through the button's built-in interaction,
130+ // long-press mode through the superview-hosted one; cover both (plus
131+ // the interaction added in init) and dedupe by identity.
132+ var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction }
133+ if let builtin = self.contextMenuInteraction {
134+ candidates.append(builtin)
135+ }
136+ if let host = longPressInteraction {
137+ candidates.append(host)
138+ }
139+ var visited: Set<ObjectIdentifier> = []
140+ for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted {
141+ // The block receives whichever menu level is currently on screen —
142+ // the navigated submenu when the user picked inside one, not the
143+ // root. Swap in the matching node from the rebuilt tree (stable
144+ // identifiers from the JS action ids) so that level updates in
145+ // place; returning an unrelated menu instead makes UIKit render it
146+ // as navigation into a foreign menu, with a stale or blank
147+ // expanded-submenu header row above the actions. The root carries
148+ // an auto-generated identifier that never matches, so it falls
149+ // through to a children-only replacement.
150+ interaction.updateVisibleMenu { [weak self] visibleMenu in
151+ guard let self = self else { return visibleMenu }
152+ if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) {
153+ return replacement
154+ }
155+ return visibleMenu.replacingChildren(menu.children)
156+ }
157+ }
158+ }
159+
160+ private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? {
161+ if menu.identifier == identifier {
162+ return menu
163+ }
164+ for element in menu.children {
165+ if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) {
166+ return match
167+ }
168+ }
169+ return nil
170+ }
171+
20+ private var longPressInteraction: UIContextMenuInteraction?172+ private var longPressInteraction: UIContextMenuInteraction?
21+173+
22+ public override func didMoveToSuperview() {174+ public override func didMoveToSuperview() {
...@@ -44,3 +196,74 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7...@@ -44,3 +196,74 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7
44 }196 }
45 197
46 public override func reactSetFrame(_ frame: CGRect) {198 public override func reactSetFrame(_ frame: CGRect) {
199diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift
200index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644
201--- a/ios/Shared/RCTMenuItem.swift
202+++ b/ios/Shared/RCTMenuItem.swift
203@@ -103,10 +103,18 @@ class RCTMenuAction {
204 subMenuActions.append(subaction.createUIMenuElement(handler))
205 }
206 var menu: UIMenu;
207+ // Stable identifiers let updateVisibleMenu match submenu nodes in
208+ // place, so a refresh while the menu is presented keeps the user's
209+ // current submenu level instead of popping back to the root.
210+ // Inline sections don't navigate, so they stay stock.
211+ let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) }
212 if self.displayInline {
213 menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions)
214 } else {
215- menu = UIMenu(title: title, image: image, children: subMenuActions)
216+ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions)
217+ }
218+ if #available(iOS 15.0, *) {
219+ menu.subtitle = subtitle
220 }
221
222 if #available(iOS 16.0, *) {
223diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
224index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644
225--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts
226+++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
227@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati
228 types here, to avoid issues while `pod install` takes place.
229 */
230
231+type SubSubAction = {
232+ id?: string;
233+ title: string;
234+ titleColor?: Int32;
235+ subtitle?: string;
236+ state?: string;
237+ image?: string;
238+ imageColor?: Int32;
239+ displayInline?: boolean;
240+ attributes?: {
241+ destructive?: boolean;
242+ disabled?: boolean;
243+ hidden?: boolean;
244+ keepsMenuPresented?: boolean;
245+ };
246+};
247 type SubAction = {
248 id?: string;
249 title: string;
250@@ -26,7 +42,11 @@ type SubAction = {
251 destructive?: boolean;
252 disabled?: boolean;
253 hidden?: boolean;
254+ keepsMenuPresented?: boolean;
255 };
256+ // One extra nesting level (menu → submenu → nested submenu leaves); the
257+ // codegen structs can't recurse, so depth is capped explicitly.
258+ subactions?: Array<SubSubAction>;
259 };
260 type MenuAction = {
261 id?: string;
262@@ -41,6 +61,7 @@ type MenuAction = {
263 destructive?: boolean;
264 disabled?: boolean;
265 hidden?: boolean;
266+ keepsMenuPresented?: boolean;
267 };
268 subactions?: Array<SubAction>;
269 };
patches/react-native-screens@4.25.2.patch+175-54
...@@ -140,11 +140,24 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758...@@ -140,11 +140,24 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758
140 140
141 NS_ASSUME_NONNULL_END141 NS_ASSUME_NONNULL_END
142diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm142diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm
143index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644143index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb2199dcbd58 100644
144--- a/ios/RNSScreenStackHeaderConfig.mm144--- a/ios/RNSScreenStackHeaderConfig.mm
145+++ b/ios/RNSScreenStackHeaderConfig.mm145+++ b/ios/RNSScreenStackHeaderConfig.mm
146@@ -30,6 +30,20 @@146@@ -25,11 +25,33 @@
147 #import "RNSSearchBar.h"
148 #import "UINavigationBar+RNSUtility.h"
149
150+#import <objc/runtime.h>
151+
152 namespace react = facebook::react;
153
147 static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17;154 static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17;
155+
156+// Keys for the last-applied JS bar button configs, associated with the
157+// navigation item so unrelated header updates (title, subtitle, tint) don't
158+// recreate the native buttons they configure.
159+static char RNSAppliedHeaderBarButtonConfigsKey;
160+static char RNSAppliedToolbarConfigsKey;
148 static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34;161 static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34;
149 162
150+static NSInteger navigationItemStyleFromCppEquivalent(163+static NSInteger navigationItemStyleFromCppEquivalent(
...@@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
164 @interface RCTImageLoader (Private)177 @interface RCTImageLoader (Private)
165 - (id<RCTImageCache>)imageCache;178 - (id<RCTImageCache>)imageCache;
166 @end179 @end
167@@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string180@@ -47,6 +69,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string
168 @end181 @end
169 182
170 @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving>183 @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving>
...@@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
174 @end187 @end
175 188
176 @implementation RNSScreenStackHeaderConfig {189 @implementation RNSScreenStackHeaderConfig {
177@@ -81,6 +98,7 @@ - (void)initProps190@@ -81,6 +106,7 @@ - (void)initProps
178 self.hidden = YES;191 self.hidden = YES;
179 _reactSubviews = [NSMutableArray new];192 _reactSubviews = [NSMutableArray new];
180 _backTitleVisible = YES;193 _backTitleVisible = YES;
...@@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
182 _blurEffect = RNSBlurEffectStyleNone;195 _blurEffect = RNSBlurEffectStyleNone;
183 }196 }
184 197
185@@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc198@@ -496,6 +522,10 @@ + (void)updateViewController:(UIViewController *)vc
186 199
187 if (shouldHide) {200 if (shouldHide) {
188 navitem.title = config.title;201 navitem.title = config.title;
...@@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
193 206
194 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items.207 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items.
195 [navctr setNavigationBarHidden:YES animated:animated];208 [navctr setNavigationBarHidden:YES animated:animated];
196@@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc209@@ -512,11 +542,19 @@ + (void)updateViewController:(UIViewController *)vc
197 }210 }
198 navitem.largeTitleDisplayMode =211 navitem.largeTitleDisplayMode =
199 config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever;212 config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever;
...@@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
213 226
214 // appearance does not apply to the tvOS so we need to use lagacy customization227 // appearance does not apply to the tvOS so we need to use lagacy customization
215 #if TARGET_OS_TV228 #if TARGET_OS_TV
216@@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc229@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc
217 // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).230 // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).
218 // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)231 // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)
219 navitem.title = config.title;232 navitem.title = config.title;
...@@ -221,32 +234,62 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -221,32 +234,62 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
221- withCurrentItems:navitem.leftBarButtonItems];234- withCurrentItems:navitem.leftBarButtonItems];
222- navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems235- navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
223- withCurrentItems:navitem.rightBarButtonItems];236- withCurrentItems:navitem.rightBarButtonItems];
224+ NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems237+ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[];
225+ withCurrentItems:navitem.leftBarButtonItems238+ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[];
226+ navigationItem:navitem];239+ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[];
227+ NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems240+ NSArray<UIBarButtonItem *> *subviewLeftItems = navitem.leftBarButtonItems ?: @[];
228+ withCurrentItems:navitem.rightBarButtonItems241+ NSArray<UIBarButtonItem *> *subviewRightItems = navitem.rightBarButtonItems ?: @[];
229+ navigationItem:navitem];242+ // The key includes the config instance's identity: cached items capture
230+ NSArray<UIBarButtonItem *> *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems243+ // this config's event emitter in their press handlers, so a remounted
231+ withCurrentItems:@[]244+ // header-config view with value-equal configs must still rebuild — reusing
245+ // the old items would dispatch presses into the dead config's emitter.
246+ NSArray *headerItemsKey =
247+ @[ @((uintptr_t)config), headerLeftConfigs, headerRightConfigs, headerCenterConfigs ];
248+ // Rebuilding bar button items creates brand-new native buttons (glass
249+ // UIButton custom views on iOS 26). Replacing them while UIKit animates an
250+ // existing one (menu capsule morph, push/pop glass transitions) strands the
251+ // animation overlay — a stuck expanded capsule or an unmasked square back
252+ // button. When the JS configs are unchanged, keep the already-applied items.
253+ // Subview-backed items are re-derived every pass, so their presence forces
254+ // the rebuild path.
255+ BOOL reuseHeaderBarButtonItems = subviewLeftItems.count == 0 && subviewRightItems.count == 0 &&
256+ [objc_getAssociatedObject(navitem, &RNSAppliedHeaderBarButtonConfigsKey) isEqual:headerItemsKey];
257+ if (!reuseHeaderBarButtonItems) {
258+ NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
259+ withCurrentItems:navitem.leftBarButtonItems
232+ navigationItem:navitem];260+ navigationItem:navitem];
261+ NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
262+ withCurrentItems:navitem.rightBarButtonItems
263+ navigationItem:navitem];
264+ NSArray<UIBarButtonItem *> *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems
265+ withCurrentItems:@[]
266+ navigationItem:navitem];
233+#if !TARGET_OS_TV267+#if !TARGET_OS_TV
234+ if (@available(iOS 16.0, *)) {268+ if (@available(iOS 16.0, *)) {
235+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems];269+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems];
236+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems];270+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems];
237+ if (@available(iOS 26.0, *)) {271+ if (@available(iOS 26.0, *)) {
238+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems];272+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems];
273+ }
274+ navitem.leftBarButtonItems = nil;
275+ navitem.rightBarButtonItems = nil;
276+ } else {
277+ navitem.leftBarButtonItems = leftBarButtonItems;
278+ navitem.rightBarButtonItems = rightBarButtonItems;
239+ }279+ }
240+ navitem.leftBarButtonItems = nil;280+#else
241+ navitem.rightBarButtonItems = nil;
242+ } else {
243+ navitem.leftBarButtonItems = leftBarButtonItems;281+ navitem.leftBarButtonItems = leftBarButtonItems;
244+ navitem.rightBarButtonItems = rightBarButtonItems;282+ navitem.rightBarButtonItems = rightBarButtonItems;
245+ }
246+#else
247+ navitem.leftBarButtonItems = leftBarButtonItems;
248+ navitem.rightBarButtonItems = rightBarButtonItems;
249+#endif283+#endif
284+ // Only dict-driven items can be reused: with subview-backed items in the
285+ // mix the applied state depends on view identity, so clear the key to
286+ // force a rebuild on the next pass.
287+ objc_setAssociatedObject(
288+ navitem,
289+ &RNSAppliedHeaderBarButtonConfigsKey,
290+ subviewLeftItems.count == 0 && subviewRightItems.count == 0 ? headerItemsKey : nil,
291+ OBJC_ASSOCIATION_RETAIN_NONATOMIC);
292+ }
250+ NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil;293+ NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil;
251+ for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) {294+ for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) {
252+ if (toolbarConfig[@"mailSearchToolbar"]) {295+ if (toolbarConfig[@"mailSearchToolbar"]) {
...@@ -269,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -269,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
269+ if (existingMailSearchToolbar == nil) {312+ if (existingMailSearchToolbar == nil) {
270+ existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag];313+ existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag];
271+ }314+ }
272+ [existingMailSearchToolbar removeFromSuperview];315+ // Reuse the live toolbar when nothing it was built from changed: rebuilding
316+ // replaces the fallback UISearchTextField with a fresh empty one, dropping
317+ // the user's in-progress search text and first responder on every unrelated
318+ // header update. Keyed on the config instance (the button/search blocks
319+ // capture its event emitter), the toolbar config values, and the host width
320+ // (the width constraint constant is resolved from it at build time).
321+ static char RNSAppliedMailSearchToolbarConfigKey;
322+ NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil
323+ ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ]
324+ : nil;
325+ BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil &&
326+ [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey)
327+ isEqual:mailSearchToolbarKey];
328+ if (!reuseMailSearchToolbar) {
329+ [existingMailSearchToolbar removeFromSuperview];
330+ }
273+331+
274+ if (mailSearchToolbarConfig != nil) {332+ if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) {
275+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)333+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
276+ if (@available(iOS 26.0, *)) {334+ if (@available(iOS 26.0, *)) {
277+ CGFloat horizontalInset = 18.0;335+ CGFloat horizontalInset = 18.0;
...@@ -304,21 +362,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -304,21 +362,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
304+362+
305+ UIView *toolbarHost = [[UIView alloc] init];363+ UIView *toolbarHost = [[UIView alloc] init];
306+ toolbarHost.tag = RNSMailSearchToolbarViewTag;364+ toolbarHost.tag = RNSMailSearchToolbarViewTag;
365+ objc_setAssociatedObject(
366+ toolbarHost,
367+ &RNSAppliedMailSearchToolbarConfigKey,
368+ mailSearchToolbarKey,
369+ OBJC_ASSOCIATION_RETAIN_NONATOMIC);
307+ toolbarHost.translatesAutoresizingMaskIntoConstraints = NO;370+ toolbarHost.translatesAutoresizingMaskIntoConstraints = NO;
308+ [chromeHostView addSubview:toolbarHost];371+ [chromeHostView addSubview:toolbarHost];
309+ // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard372+ // The screen stays mounted beneath pushed routes, so its keyboard layout
310+ // layout guide no longer rests at the bottom safe-area edge while the373+ // guide can track a keyboard owned by another screen. Keep the toolbar at
311+ // keyboard is hidden, which pushed the toolbar offscreen. The required374+ // rest unless its own search field is editing.
312+ // resting position is the safe area; the keyboard guide only pulls the
313+ // toolbar up when it actually tracks a visible keyboard.
314+ NSLayoutConstraint *keyboardAvoidConstraint =375+ NSLayoutConstraint *keyboardAvoidConstraint =
315+ [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor376+ [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor
316+ constant:-toolbarBottomSpacing];377+ constant:-toolbarBottomSpacing];
317+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh;378+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow;
318+ NSLayoutConstraint *restingBottomConstraint =379+ NSLayoutConstraint *restingBottomConstraint =
319+ [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor380+ [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
320+ constant:-toolbarBottomSpacing];381+ constant:-toolbarBottomSpacing];
321+ restingBottomConstraint.priority = UILayoutPriorityDefaultLow;382+ restingBottomConstraint.priority = UILayoutPriorityDefaultHigh;
322+ [NSLayoutConstraint activateConstraints:@[383+ [NSLayoutConstraint activateConstraints:@[
323+ [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor],384+ [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor],
324+ [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor385+ [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
...@@ -330,6 +391,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -330,6 +391,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
330+ ]];391+ ]];
331+ [chromeHostView bringSubviewToFront:toolbarHost];392+ [chromeHostView bringSubviewToFront:toolbarHost];
332+393+
394+ void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) {
395+ BOOL isEditing = textField.isFirstResponder;
396+ keyboardAvoidConstraint.priority =
397+ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow;
398+ restingBottomConstraint.priority =
399+ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh;
400+
401+ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint;
402+ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint;
403+ __weak UIView *weakChromeHostView = chromeHostView;
404+ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin";
405+ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end";
406+ [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin];
407+ [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd];
408+ [textField addAction:[UIAction actionWithTitle:@""
409+ image:nil
410+ identifier:beginActionIdentifier
411+ handler:^(__kindof UIAction *_Nonnull action) {
412+ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow;
413+ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh;
414+ [weakChromeHostView setNeedsLayout];
415+ }]
416+ forControlEvents:UIControlEventEditingDidBegin];
417+ [textField addAction:[UIAction actionWithTitle:@""
418+ image:nil
419+ identifier:endActionIdentifier
420+ handler:^(__kindof UIAction *_Nonnull action) {
421+ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow;
422+ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh;
423+ [weakChromeHostView setNeedsLayout];
424+ }]
425+ forControlEvents:UIControlEventEditingDidEnd];
426+ };
427+
333+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];428+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];
334+ glassEffect.interactive = YES;429+ glassEffect.interactive = YES;
335+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];430+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];
...@@ -411,6 +506,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -411,6 +506,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
411+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;506+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
412+ searchBar.searchTextField.textColor = UIColor.labelColor;507+ searchBar.searchTextField.textColor = UIColor.labelColor;
413+ searchBar.searchTextField.tintColor = UIColor.labelColor;508+ searchBar.searchTextField.tintColor = UIColor.labelColor;
509+ configureKeyboardTracking(searchBar.searchTextField);
414+ if (placeholder != nil) {510+ if (placeholder != nil) {
415+ searchBar.searchTextField.attributedPlaceholder =511+ searchBar.searchTextField.attributedPlaceholder =
416+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];512+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
...@@ -443,6 +539,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -443,6 +539,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
443+ searchField.adjustsFontForContentSizeCategory = YES;539+ searchField.adjustsFontForContentSizeCategory = YES;
444+ searchField.textColor = UIColor.labelColor;540+ searchField.textColor = UIColor.labelColor;
445+ searchField.tintColor = UIColor.labelColor;541+ searchField.tintColor = UIColor.labelColor;
542+ configureKeyboardTracking(searchField);
446+ searchField.translatesAutoresizingMaskIntoConstraints = NO;543+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
447+ [glassView.contentView addSubview:searchField];544+ [glassView.contentView addSubview:searchField];
448+ [NSLayoutConstraint activateConstraints:@[545+ [NSLayoutConstraint activateConstraints:@[
...@@ -491,20 +588,34 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -491,20 +588,34 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
491+ navigationToolbarConfigs = @[];588+ navigationToolbarConfigs = @[];
492+ }589+ }
493+590+
494+ NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs591+ NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[];
495+ withCurrentItems:@[]592+ // Same reuse rule as the header item groups above (including the config
496+ navigationItem:navitem];593+ // identity — cached toolbar items capture this config's event emitter).
497+ if (toolbarItems.count > 0) {594+ // The top-view-controller and hidden-state checks scope the skip to
498+ vc.toolbarItems = toolbarItems;595+ // same-screen refreshes, so transitions between screens with different
499+ [navctr setToolbarHidden:NO animated:animated];596+ // toolbars still reapply.
500+ } else {597+ NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ];
501+ vc.toolbarItems = nil;598+ BOOL reuseToolbarItems = navctr.topViewController == vc &&
502+ [navctr setToolbarHidden:YES animated:animated];599+ navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) &&
600+ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey];
601+ if (!reuseToolbarItems) {
602+ NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs
603+ withCurrentItems:@[]
604+ navigationItem:navitem];
605+ if (toolbarItems.count > 0) {
606+ vc.toolbarItems = toolbarItems;
607+ [navctr setToolbarHidden:NO animated:animated];
608+ } else {
609+ vc.toolbarItems = nil;
610+ [navctr setToolbarHidden:YES animated:animated];
611+ }
612+ objc_setAssociatedObject(
613+ navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
503+ }614+ }
504 615
505 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items616 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
506 // (setting nav bar visibility should be done after `navitem.*BarButtonItems`).617 // (setting nav bar visibility should be done after `navitem.*BarButtonItems`).
507@@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *618@@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
508 619
509 - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts620 - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts
510 withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems621 withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems
...@@ -512,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -512,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
512 {623 {
513 if (dicts.count == 0) {624 if (dicts.count == 0) {
514 return currentItems;625 return currentItems;
515@@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *626@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
516 [items addObjectsFromArray:currentItems];627 [items addObjectsFromArray:currentItems];
517 for (NSUInteger i = 0; i < dicts.count; i++) {628 for (NSUInteger i = 0; i < dicts.count; i++) {
518 NSDictionary *dict = dicts[i];629 NSDictionary *dict = dicts[i];
...@@ -700,7 +811,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -700,7 +811,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
700+ }811+ }
701+ }812+ }
702+#endif813+#endif
703+ if (index != nil && index.integerValue < items.count) {814+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
704+ [items insertObject:item atIndex:index.integerValue];815+ [items insertObject:item atIndex:index.integerValue];
705+ } else {816+ } else {
706+ [items addObject:item];817+ [items addObject:item];
...@@ -711,7 +822,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -711,7 +822,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
711 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict822 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
712 action:^(NSString *buttonId) {823 action:^(NSString *buttonId) {
713 auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(824 auto eventEmitter = std::static_pointer_cast<const facebook::react::RNSScreenStackHeaderConfigEventEmitter>(
714@@ -809,11 +1306,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *825@@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
826 }
827 imageLoader:_imageLoader];
828 NSNumber *index = dict[@"index"];
829- if (index.integerValue < items.count) {
830+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
831 [items insertObject:item atIndex:index.integerValue];
832 } else {
715 [items addObject:item];833 [items addObject:item];
716 }834 }
717 } else if (dict[@"spacing"]) {835 } else if (dict[@"spacing"]) {
...@@ -729,9 +847,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -729,9 +847,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
729+ item.width = [spacingValue doubleValue];847+ item.width = [spacingValue doubleValue];
730+ }848+ }
731 NSNumber *index = dict[@"index"];849 NSNumber *index = dict[@"index"];
732 if (index.integerValue < items.count) {850- if (index.integerValue < items.count) {
851+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
733 [items insertObject:item atIndex:index.integerValue];852 [items insertObject:item atIndex:index.integerValue];
734@@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *853 } else {
854 [items addObject:item];
855@@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
735 return items;856 return items;
736 }857 }
737 858
...@@ -779,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -779,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
779 RNS_IGNORE_SUPER_CALL_BEGIN900 RNS_IGNORE_SUPER_CALL_BEGIN
780 - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex901 - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
781 {902 {
782@@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::903@@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
783 }904 }
784 905
785 _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);906 _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
...@@ -788,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -788,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
788 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {909 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
789 _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);910 _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
790 }911 }
791@@ -1038,6 +1582,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::912@@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
792 _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;913 _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
793 _backButtonDisplayMode =914 _backButtonDisplayMode =
794 [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];915 [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
...@@ -796,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d...@@ -796,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
796 917
797 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {918 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
798 _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];919 _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle];
799@@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::920@@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
800 _headerRightBarButtonItems = array;921 _headerRightBarButtonItems = array;
801 }922 }
802 923
pnpm-lock.yaml+33-29
...@@ -12,6 +12,9 @@ catalogs:...@@ -12,6 +12,9 @@ catalogs:
12 '@effect/tsgo':12 '@effect/tsgo':
13 specifier: 0.13.213 specifier: 0.13.2
14 version: 0.13.214 version: 0.13.2
15 '@legendapp/list':
16 specifier: 3.3.5
17 version: 3.3.5
15 '@noble/curves':18 '@noble/curves':
16 specifier: 1.9.119 specifier: 1.9.1
17 version: 1.9.120 version: 1.9.1
...@@ -69,12 +72,13 @@ overrides:...@@ -69,12 +72,13 @@ overrides:
69packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE=72packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE=
7073
71patchedDependencies:74patchedDependencies:
75 '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1
72 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b76 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
73 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff4677 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46
74 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c878 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
75 '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f0979 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5
76 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa80 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
77 '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae81 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
78 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a78482 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784
79 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c71727383 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273
80 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb684 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
...@@ -82,7 +86,7 @@ patchedDependencies:...@@ -82,7 +86,7 @@ patchedDependencies:
82 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d386 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
83 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae00887 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008
84 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b608842267588 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
85 react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de889 react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e
8690
87importers:91importers:
8892
...@@ -197,7 +201,7 @@ importers:...@@ -197,7 +201,7 @@ importers:
197 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)201 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)
198 '@clerk/expo':202 '@clerk/expo':
199 specifier: 4.2.0203 specifier: 4.2.0
200 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)204 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)
201 '@effect/atom-react':205 '@effect/atom-react':
202 specifier: 4.0.0-beta.103206 specifier: 4.0.0-beta.103
203 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0)207 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:...@@ -211,8 +215,8 @@ importers:
211 specifier: ~56.0.18215 specifier: ~56.0.18
212 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)216 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)
213 '@legendapp/list':217 '@legendapp/list':
214 specifier: 3.3.3218 specifier: 'catalog:'
215 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)219 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)
216 '@noble/curves':220 '@noble/curves':
217 specifier: 'catalog:'221 specifier: 'catalog:'
218 version: 1.9.1222 version: 1.9.1
...@@ -224,7 +228,7 @@ importers:...@@ -224,7 +228,7 @@ importers:
224 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)228 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)
225 '@react-native-menu/menu':229 '@react-native-menu/menu':
226 specifier: ^2.0.0230 specifier: ^2.0.0
227 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)231 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)
228 '@react-navigation/elements':232 '@react-navigation/elements':
229 specifier: 2.9.26233 specifier: 2.9.26
230 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)234 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)
...@@ -233,7 +237,7 @@ importers:...@@ -233,7 +237,7 @@ importers:
233 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)237 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)
234 '@react-navigation/native-stack':238 '@react-navigation/native-stack':
235 specifier: 7.17.6239 specifier: 7.17.6
236 version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)240 version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)
237 '@shikijs/core':241 '@shikijs/core':
238 specifier: 4.2.0242 specifier: 4.2.0
239 version: 4.2.0243 version: 4.2.0
...@@ -398,7 +402,7 @@ importers:...@@ -398,7 +402,7 @@ importers:
398 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)402 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)
399 react-native-screens:403 react-native-screens:
400 specifier: 4.25.2404 specifier: 4.25.2
401 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)405 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)
402 react-native-shiki-engine:406 react-native-shiki-engine:
403 specifier: ^0.3.12407 specifier: ^0.3.12
404 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)408 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:...@@ -537,8 +541,8 @@ importers:
537 specifier: ^0.9.0541 specifier: ^0.9.0
538 version: 0.9.0542 version: 0.9.0
539 '@legendapp/list':543 '@legendapp/list':
540 specifier: 3.3.3544 specifier: 'catalog:'
541 version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)545 version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
542 '@lexical/react':546 '@lexical/react':
543 specifier: ^0.41.0547 specifier: ^0.41.0
544 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)548 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:...@@ -2950,8 +2954,8 @@ packages:
2950 resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}2954 resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}
2951 engines: {node: '>=12'}2955 engines: {node: '>=12'}
29522956
2953 '@legendapp/list@3.3.3':2957 '@legendapp/list@3.3.5':
2954 resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==}2958 resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==}
2955 peerDependencies:2959 peerDependencies:
2956 react: '*'2960 react: '*'
2957 react-dom: '*'2961 react-dom: '*'
...@@ -11584,7 +11588,7 @@ snapshots:...@@ -11584,7 +11588,7 @@ snapshots:
11584 electron-store: 8.2.011588 electron-store: 8.2.0
11585 react-dom: 19.2.6(react@19.2.6)11589 react-dom: 19.2.6(react@19.2.6)
1158611590
11587 '@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)':11591 '@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)':
11588 dependencies:11592 dependencies:
11589 '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)11593 '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
11590 '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)11594 '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
...@@ -12253,7 +12257,7 @@ snapshots:...@@ -12253,7 +12257,7 @@ snapshots:
12253 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)12257 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
12254 zod: 3.25.7612258 zod: 3.25.76
12255 optionalDependencies:12259 optionalDependencies:
12256 expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7)12260 expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8)
12257 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)12261 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)
12258 transitivePeerDependencies:12262 transitivePeerDependencies:
12259 - '@expo/dom-webview'12263 - '@expo/dom-webview'
...@@ -12329,7 +12333,7 @@ snapshots:...@@ -12329,7 +12333,7 @@ snapshots:
12329 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)12333 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
12330 zod: 3.25.7612334 zod: 3.25.76
12331 optionalDependencies:12335 optionalDependencies:
12332 expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f)12336 expo-router: 56.2.11(db5c693a26481047569df6781f34db9f)
12333 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)12337 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)
12334 transitivePeerDependencies:12338 transitivePeerDependencies:
12335 - '@expo/dom-webview'12339 - '@expo/dom-webview'
...@@ -12669,7 +12673,7 @@ snapshots:...@@ -12669,7 +12673,7 @@ snapshots:
12669 react: 19.2.312673 react: 19.2.3
12670 optionalDependencies:12674 optionalDependencies:
12671 '@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)12675 '@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)
12672 expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7)12676 expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8)
12673 react-dom: 19.2.3(react@19.2.3)12677 react-dom: 19.2.3(react@19.2.3)
12674 transitivePeerDependencies:12678 transitivePeerDependencies:
12675 - supports-color12679 - supports-color
...@@ -12684,7 +12688,7 @@ snapshots:...@@ -12684,7 +12688,7 @@ snapshots:
12684 react: 19.2.612688 react: 19.2.6
12685 optionalDependencies:12689 optionalDependencies:
12686 '@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)12690 '@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)
12687 expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f)12691 expo-router: 56.2.11(db5c693a26481047569df6781f34db9f)
12688 react-dom: 19.2.6(react@19.2.6)12692 react-dom: 19.2.6(react@19.2.6)
12689 transitivePeerDependencies:12693 transitivePeerDependencies:
12690 - supports-color12694 - supports-color
...@@ -12987,7 +12991,7 @@ snapshots:...@@ -12987,7 +12991,7 @@ snapshots:
12987 dependencies:12991 dependencies:
12988 jsbi: 4.3.212992 jsbi: 4.3.2
1298912993
12990 '@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)':12994 '@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)':
12991 dependencies:12995 dependencies:
12992 react: 19.2.312996 react: 19.2.3
12993 use-sync-external-store: 1.6.0(react@19.2.3)12997 use-sync-external-store: 1.6.0(react@19.2.3)
...@@ -12995,7 +12999,7 @@ snapshots:...@@ -12995,7 +12999,7 @@ snapshots:
12995 react-dom: 19.2.3(react@19.2.3)12999 react-dom: 19.2.3(react@19.2.3)
12996 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)13000 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)
1299713001
12998 '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':13002 '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12999 dependencies:13003 dependencies:
13000 react: 19.2.613004 react: 19.2.6
13001 use-sync-external-store: 1.6.0(react@19.2.6)13005 use-sync-external-store: 1.6.0(react@19.2.6)
...@@ -14091,7 +14095,7 @@ snapshots:...@@ -14091,7 +14095,7 @@ snapshots:
14091 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)14095 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)
14092 optional: true14096 optional: true
1409314097
14094 '@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)':14098 '@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)':
14095 dependencies:14099 dependencies:
14096 react: 19.2.314100 react: 19.2.3
14097 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)14101 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:...@@ -14269,7 +14273,7 @@ snapshots:
14269 optionalDependencies:14273 optionalDependencies:
14270 '@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)14274 '@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)
1427114275
14272 '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)':14276 '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)':
14273 dependencies:14277 dependencies:
14274 '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)14278 '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)
14275 '@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)14279 '@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:...@@ -14277,7 +14281,7 @@ snapshots:
14277 react: 19.2.314281 react: 19.2.3
14278 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)14282 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)
14279 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)14283 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)
14280 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)14284 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)
14281 sf-symbols-typescript: 2.2.014285 sf-symbols-typescript: 2.2.0
14282 warn-once: 0.1.114286 warn-once: 0.1.1
14283 transitivePeerDependencies:14287 transitivePeerDependencies:
...@@ -17056,7 +17060,7 @@ snapshots:...@@ -17056,7 +17060,7 @@ snapshots:
17056 - supports-color17060 - supports-color
17057 - typescript17061 - typescript
1705817062
17059 expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7):17063 expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8):
17060 dependencies:17064 dependencies:
17061 '@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)17065 '@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)
17062 '@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)17066 '@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:...@@ -17087,7 +17091,7 @@ snapshots:
17087 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)17091 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)
17088 react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5)17092 react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5)
17089 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)17093 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)
17090 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)17094 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)
17091 server-only: 0.0.117095 server-only: 0.0.1
17092 sf-symbols-typescript: 2.2.017096 sf-symbols-typescript: 2.2.0
17093 shallowequal: 1.1.017097 shallowequal: 1.1.0
...@@ -17107,7 +17111,7 @@ snapshots:...@@ -17107,7 +17111,7 @@ snapshots:
17107 - supports-color17111 - supports-color
17108 optional: true17112 optional: true
1710917113
17110 expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f):17114 expo-router@56.2.11(db5c693a26481047569df6781f34db9f):
17111 dependencies:17115 dependencies:
17112 '@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)17116 '@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)
17113 '@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)17117 '@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:...@@ -17138,7 +17142,7 @@ snapshots:
17138 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)17142 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)
17139 react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)17143 react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)
17140 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)17144 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)
17141 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)17145 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)
17142 server-only: 0.0.117146 server-only: 0.0.1
17143 sf-symbols-typescript: 2.2.017147 sf-symbols-typescript: 2.2.0
17144 shallowequal: 1.1.017148 shallowequal: 1.1.0
...@@ -19953,14 +19957,14 @@ snapshots:...@@ -19953,14 +19957,14 @@ snapshots:
19953 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)19957 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)
19954 optional: true19958 optional: true
1995519959
19956 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):19960 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):
19957 dependencies:19961 dependencies:
19958 react: 19.2.319962 react: 19.2.3
19959 react-freeze: 1.0.4(react@19.2.3)19963 react-freeze: 1.0.4(react@19.2.3)
19960 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)19964 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)
19961 warn-once: 0.1.119965 warn-once: 0.1.1
1996219966
19963 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):19967 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):
19964 dependencies:19968 dependencies:
19965 react: 19.2.619969 react: 19.2.6
19966 react-freeze: 1.0.4(react@19.2.6)19970 react-freeze: 1.0.4(react@19.2.6)
pnpm-workspace.yaml+4-1
...@@ -39,6 +39,7 @@ catalog:...@@ -39,6 +39,7 @@ catalog:
39 "@effect/sql-sqlite-bun": 4.0.0-beta.10339 "@effect/sql-sqlite-bun": 4.0.0-beta.103
40 "@effect/tsgo": 0.13.240 "@effect/tsgo": 0.13.2
41 "@effect/vitest": 4.0.0-beta.10341 "@effect/vitest": 4.0.0-beta.103
42 "@legendapp/list": 3.3.5
42 "@noble/curves": 1.9.143 "@noble/curves": 1.9.1
43 "@noble/hashes": 1.8.044 "@noble/hashes": 1.8.0
44 "@pierre/diffs": 1.3.0-beta.1045 "@pierre/diffs": 1.3.0-beta.10
...@@ -74,6 +75,7 @@ minimumReleaseAgeExclude:...@@ -74,6 +75,7 @@ minimumReleaseAgeExclude:
74 - "@effect/vitest@4.0.0-beta.103"75 - "@effect/vitest@4.0.0-beta.103"
75 - alchemy@2.0.0-beta.6576 - alchemy@2.0.0-beta.65
76 - effect@4.0.0-beta.10377 - effect@4.0.0-beta.103
78 - "@legendapp/list@3.3.5"
7779
78overrides:80overrides:
79 "@clerk/backend": "catalog:"81 "@clerk/backend": "catalog:"
...@@ -122,10 +124,11 @@ packageExtensions:...@@ -122,10 +124,11 @@ packageExtensions:
122 vite: "catalog:"124 vite: "catalog:"
123125
124patchedDependencies:126patchedDependencies:
127 "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch
125 "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch128 "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch
126 "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch129 "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch
127 "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch130 "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch
128 "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch131 "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch
129 "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch132 "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch
130 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch133 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
131 "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch134 "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch