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 @@
4949 "@expo-google-fonts/dm-sans": "^0.4.2",
5050 "@expo/metro-runtime": "~56.0.15",
5151 "@expo/ui": "~56.0.18",
52 "@legendapp/list": "3.3.3",
52 "@legendapp/list": "catalog:",
5353 "@noble/curves": "catalog:",
5454 "@noble/hashes": "catalog:",
5555 "@pierre/diffs": "catalog:",
apps/mobile/src/Stack.tsx+20-13
......@@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText";
1818import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
1919import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
2020import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
21import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent";
2221import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
2322import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation";
2423import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen";
......@@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = {
134133 presentation: "fullScreenModal",
135134};
136135
137const SettingsSheetStack = createNativeStackNavigator({
136const SettingsContentStack = createNativeStackNavigator({
138137 initialRouteName: "Settings",
139138 screenOptions: {
140139 ...GLASS_HEADER_OPTIONS,
......@@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({
198197 title: "Usage",
199198 },
200199 }),
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 }),
201216 SettingsAuth: createNativeStackScreen({
202217 screen: SettingsAuthRouteScreen,
203218 linking: "auth",
204 options: {
205 title: "Sign in",
206 },
207219 }),
208220 SettingsWaitlist: createNativeStackScreen({
209221 // Keep the old deep link working after the Connect GA launch.
210222 screen: SettingsAuthRouteScreen,
211223 linking: "waitlist",
212 options: {
213 title: "Sign in",
214 },
215224 }),
216225 },
217226});
......@@ -347,11 +356,9 @@ function RootStackLayout(props: {
347356 <HardwareKeyboardCommandProvider pathname={pathname}>
348357 <ThreadOutboxDrainWorker />
349358 <ShowcaseCaptureCoordinator pathname={pathname} />
350 <ClerkSettingsSheetDetentProvider initiallyExpanded={false}>
351 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
352 {props.children}
353 </AdaptiveWorkspaceLayout>
354 </ClerkSettingsSheetDetentProvider>
359 <AdaptiveWorkspaceLayout pathname={workspacePathname}>
360 {props.children}
361 </AdaptiveWorkspaceLayout>
355362 </HardwareKeyboardCommandProvider>
356363 );
357364}
apps/mobile/src/components/ControlPill.tsx+26-1
......@@ -6,6 +6,7 @@ import {
66 type ComponentProps,
77 type ReactElement,
88 type ReactNode,
9 useRef,
910} from "react";
1011import { Platform, Pressable, useColorScheme, View } from "react-native";
1112import { useThemeColor } from "../lib/useThemeColor";
......@@ -21,10 +22,31 @@ export function ControlPill(props: {
2122 readonly label?: string;
2223 readonly accessibilityLabel?: string;
2324 readonly onPress?: () => void;
25 readonly activateOnPressIn?: boolean;
2426 readonly variant?: "circle" | "pill" | "primary" | "danger";
2527 readonly disabled?: boolean;
28 readonly className?: string;
2629}) {
2730 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
2951 const iconColor = useThemeColor("--color-icon");
3052 const iconSubtle = useThemeColor("--color-icon-subtle");
......@@ -54,6 +76,7 @@ export function ControlPill(props: {
5476 : variant === "danger"
5577 ? "bg-danger"
5678 : "bg-subtle",
79 props.className,
5780 );
5881 const labelClassName = cn(
5982 "text-center text-xs font-t3-bold",
......@@ -68,7 +91,9 @@ export function ControlPill(props: {
6891 <Pressable
6992 accessibilityLabel={props.accessibilityLabel ?? props.label}
7093 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}
7297 disabled={props.disabled}
7398 className={containerClassName}
7499 >
apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx+1-4
......@@ -5,7 +5,6 @@ import { useFocusEffect } from "@react-navigation/native";
55import { useCallback, useMemo, useState } from "react";
66
77import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
8import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
98import { useArchivedThreadListActions } from "../home/useThreadListActions";
109import {
1110 ArchivedThreadsScreen,
......@@ -18,7 +17,6 @@ import {
1817} from "./useArchivedThreadSnapshots";
1918
2019export function ArchivedThreadsRouteScreen() {
21 const { expand } = useClerkSettingsSheetDetent();
2220 const { savedConnectionsById } = useSavedRemoteConnections();
2321 const [searchQuery, setSearchQuery] = useState("");
2422 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(null);
......@@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() {
7068
7169 useFocusEffect(
7270 useCallback(() => {
73 expand();
7471 refresh();
75 }, [expand, refresh]),
72 }, [refresh]),
7673 );
7774
7875 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";
66import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding";
77import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut";
88
9// Sign-in happens inside the Settings sheet; give its detent/session-state
10// transitions a beat to settle before presenting another formSheet on top.
9// Sign-in happens inside the Settings sheet; give its session-state transition
10// a beat to settle before presenting another formSheet on top.
1111const PRESENT_ONBOARDING_DELAY_MS = 600;
1212
1313/**
apps/mobile/src/features/home/HomeRouteScreen.tsx+24-5
......@@ -134,7 +134,10 @@ export function HomeRouteScreen() {
134134 <NativeStackScreenOptions
135135 options={getConnectionAwareBrandHeaderOptions({
136136 onOpenEnvironments: () =>
137 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }),
137 navigation.navigate("SettingsSheet", {
138 screen: "SettingsContent",
139 params: { screen: "SettingsEnvironments" },
140 }),
138141 })}
139142 />
140143 <HomeHeader
......@@ -148,9 +151,17 @@ export function HomeRouteScreen() {
148151 onEnvironmentChange={setSelectedEnvironmentId}
149152 onProjectChange={setSelectedProjectKey}
150153 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 })
152164 }
153 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
154165 onProjectSortOrderChange={setProjectSortOrder}
155166 onSearchQueryChange={setSearchQuery}
156167 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
......@@ -161,7 +172,10 @@ export function HomeRouteScreen() {
161172 catalogState={catalogState}
162173 environments={environments}
163174 onAddConnection={() =>
164 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })
175 navigation.navigate("SettingsSheet", {
176 screen: "SettingsContent",
177 params: { screen: "SettingsEnvironmentNew" },
178 })
165179 }
166180 onArchiveThread={archiveThread}
167181 onDeleteThread={confirmDeleteThread}
......@@ -174,7 +188,12 @@ export function HomeRouteScreen() {
174188 onMovePinnedThread={movePinnedThread}
175189 onEnvironmentChange={setSelectedEnvironmentId}
176190 onProjectChange={setSelectedProjectKey}
177 onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })}
191 onOpenSettings={() =>
192 navigation.navigate("SettingsSheet", {
193 screen: "SettingsContent",
194 params: { screen: "Settings" },
195 })
196 }
178197 onProjectSortOrderChange={setProjectSortOrder}
179198 onSearchQueryChange={setSearchQuery}
180199 onSelectThread={(thread) => {
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+8-2
......@@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent(
429429 );
430430
431431 const handleOpenSettings = useCallback(() => {
432 navigation.navigate("SettingsSheet", { screen: "Settings" });
432 navigation.navigate("SettingsSheet", {
433 screen: "SettingsContent",
434 params: { screen: "Settings" },
435 });
433436 }, [navigation]);
434437
435438 // Minted here (root stack navigation) so the sidebar pane stays free of
436439 // navigation hooks — on iOS it renders inside an independent nav tree.
437440 const handleOpenEnvironmentSettings = useCallback(() => {
438 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" });
441 navigation.navigate("SettingsSheet", {
442 screen: "SettingsContent",
443 params: { screen: "SettingsEnvironments" },
444 });
439445 }, [navigation]);
440446
441447 const handleNewThreadInProject = useCallback(
apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx+16-17
......@@ -1,8 +1,7 @@
11import { useAuth } from "@clerk/expo";
22import { AuthView, UserProfileView } from "@clerk/expo/native";
33import { StackActions, useNavigation } from "@react-navigation/native";
4import { NativeStackScreenOptions } from "../../native/StackHeader";
5import { useCallback, useEffect } from "react";
4import { useCallback, useLayoutEffect } from "react";
65import { View } from "react-native";
76
87import { hasCloudPublicConfig } from "../cloud/publicConfig";
......@@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig";
109export function SettingsAuthRouteScreen() {
1110 const navigation = useNavigation();
1211
13 useEffect(() => {
12 useLayoutEffect(() => {
1413 if (!hasCloudPublicConfig()) {
15 navigation.dispatch(StackActions.replace("Settings"));
14 navigation.dispatch(StackActions.replace("SettingsContent"));
1615 }
1716 }, [navigation]);
1817
......@@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() {
2221function ConfiguredSettingsAuthRouteScreen() {
2322 const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
2423 const navigation = useNavigation();
25 const handleHostBack = useCallback(() => navigation.goBack(), [navigation]);
24 const handleHostBack = useCallback(
25 () => navigation.dispatch(StackActions.popTo("SettingsContent")),
26 [navigation],
27 );
2628
2729 return (
28 <>
29 <NativeStackScreenOptions options={{ headerShown: false }} />
30 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
31 {isLoaded ? (
32 isSignedIn ? (
33 <UserProfileView isDismissible={false} onHostBack={handleHostBack} />
34 ) : (
35 <AuthView isDismissible={false} onHostBack={handleHostBack} />
36 )
37 ) : null}
38 </View>
39 </>
30 <View collapsable={false} className="flex-1 overflow-hidden bg-sheet">
31 {isLoaded ? (
32 isSignedIn ? (
33 <UserProfileView isDismissible={false} onHostBack={handleHostBack} />
34 ) : (
35 <AuthView isDismissible={false} onHostBack={handleHostBack} />
36 )
37 ) : null}
38 </View>
4039 );
4140}
apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx+8-2
......@@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() {
9898 accessibilityLabel: "Add environment",
9999 icon: "plus",
100100 onPress: () =>
101 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }),
101 navigation.navigate("SettingsSheet", {
102 screen: "SettingsContent",
103 params: { screen: "SettingsEnvironmentNew" },
104 }),
102105 },
103106 ]}
104107 />
......@@ -108,7 +111,10 @@ export function SettingsEnvironmentsRouteScreen() {
108111 <NativeHeaderToolbar.Button
109112 icon="plus"
110113 onPress={() =>
111 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" })
114 navigation.navigate("SettingsSheet", {
115 screen: "SettingsContent",
116 params: { screen: "SettingsEnvironmentNew" },
117 })
112118 }
113119 separateBackground
114120 tintColor={headerIconColor}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+6-13
......@@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo";
22import { useAtomSet, useAtomValue } from "@effect/atom-react";
33import Constants from "expo-constants";
44import * as Notifications from "expo-notifications";
5import * as Updates from "expo-updates";
65import { useNavigation } from "@react-navigation/native";
76import { NativeStackScreenOptions } from "../../native/StackHeader";
87import { SymbolView } from "../../components/AppSymbol";
......@@ -30,7 +29,6 @@ import {
3029 subscribeAgentAwarenessRegistrationStatus,
3130} from "../agent-awareness/remoteRegistration";
3231import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState";
33import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent";
3432import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig";
3533import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
3634import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
......@@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/
4038import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
4139import {
4240 type AppUpdateCheckState,
41 isAppUpdateCheckAvailable,
4342 registerHiddenUpdateTap,
4443 runAppUpdateCheck,
4544} from "../updates/app-updates";
......@@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() {
147146 const agentAwarenessPushAvailable = supportsAgentAwarenessPush();
148147 const insets = useSafeAreaInsets();
149148 const navigation = useNavigation();
150 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
151149 const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
152150 const { user } = useUser();
153151 const { savedConnectionsById } = useSavedRemoteConnections();
......@@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() {
436434
437435 const openAccount = useCallback(() => {
438436 if (!isLoaded) return;
439 if (!isSignedIn) {
440 expandClerkSheet();
441 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
442 return;
443 }
444 expandClerkSheet();
445437 navigation.navigate("SettingsSheet", { screen: "SettingsAuth" });
446 }, [expandClerkSheet, isLoaded, isSignedIn, navigation]);
438 }, [isLoaded, navigation]);
447439
448440 return (
449441 <View collapsable={false} className="flex-1 bg-sheet">
......@@ -577,6 +569,7 @@ function AppSettingsSection() {
577569 const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production";
578570 const variantLabel = variant === "production" ? "" : capitalize(variant);
579571 const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version;
572 const updateCheckAvailable = isAppUpdateCheckAvailable();
580573 const busy =
581574 updateState === "checking" || updateState === "downloading" || updateState === "restarting";
582575
......@@ -604,13 +597,13 @@ function AppSettingsSection() {
604597 }, []);
605598
606599 const handleVersionPress = useCallback(() => {
607 if (!Updates.isEnabled || updateInFlight.current) return;
600 if (!updateCheckAvailable || updateInFlight.current) return;
608601 const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current);
609602 hiddenUpdateTapCount.current = tap.nextCount;
610603 if (tap.shouldCheck) {
611604 void checkForUpdate();
612605 }
613 }, [checkForUpdate]);
606 }, [checkForUpdate, updateCheckAvailable]);
614607
615608 const statusLabel =
616609 updateState === "checking"
......@@ -646,7 +639,7 @@ function AppSettingsSection() {
646639 <SettingsSection title="App">
647640 <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
648641 <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />
649 {Updates.isEnabled ? (
642 {updateCheckAvailable ? (
650643 <Pressable
651644 accessibilityLabel={`Version ${versionLabel}`}
652645 accessibilityRole="text"
apps/mobile/src/features/settings/components/SettingsRow.tsx+2-1
......@@ -64,7 +64,8 @@ export function SettingsRow(props: {
6464 disabled={props.disabled}
6565 onPress={() =>
6666 navigation.navigate("SettingsSheet", {
67 screen: target,
67 screen: "SettingsContent",
68 params: { screen: target },
6869 })
6970 }
7071 >
apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx+20-8
......@@ -1,6 +1,12 @@
11import { useEffect, useRef, useState } from "react";
22import { 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";
410import { AsyncResult } from "effect/unstable/reactivity";
511
612import { useConnectionController } from "../connection/useConnectionController";
......@@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry";
2531const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1";
2632const SHOWCASE_THREAD_ID = "remote-command-center";
2733
34type ShowcaseResetRoute = PartialState<NavigationState>["routes"][number];
35
2836function sceneFromPathname(pathname: string): ShowcaseScene | null {
2937 const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname;
3038 if (routePath === "/settings" || routePath.endsWith("/settings/environments")) {
......@@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string })
166174 navigation.dispatch(StackActions.popToTop());
167175 return;
168176 }
169 const routes: Array<{
170 name: string;
171 params?: Record<string, unknown>;
172 state?: { index: number; routes: Array<{ name: string }> };
173 }> = [{ name: "Home" }];
177 const routes: ShowcaseResetRoute[] = [{ name: "Home" }];
174178 if (requestedScene === "environments") {
175179 routes.push({
176180 name: "SettingsSheet",
177181 state: {
178 index: 1,
179 routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }],
182 index: 0,
183 routes: [
184 {
185 name: "SettingsContent",
186 state: {
187 index: 1,
188 routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }],
189 },
190 },
191 ],
180192 },
181193 });
182194 } else {
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+23-4
......@@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
22import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native";
33import { useCallback, useEffect, useMemo, useRef, useState } from "react";
44import { 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";
610import { useSafeAreaInsets } from "react-native-safe-area-context";
711import { useThemeColor } from "../../lib/useThemeColor";
812import { useFontFamily } from "../../lib/useFontFamily";
......@@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: {
985989 // The draft is a thread that doesn't exist yet, so it mirrors the thread
986990 // page: in-screen header, empty feed canvas above, and the same floating
987991 // 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.
9881003 return (
9891004 <View className="flex-1 bg-screen">
9901005 <NativeStackScreenOptions options={{ headerShown: false }} />
9911006 <AndroidScreenHeader title="New Thread" onBack={() => navigation.goBack()} />
9921007
993 <KeyboardAvoidingView automaticOffset behavior="padding" className="flex-1">
994 <View className="flex-1" />
1008 <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 >
9961015 <View
9971016 className="px-4 pt-2"
9981017 style={{
......@@ -1056,7 +1075,7 @@ export function NewTaskDraftScreen(props: {
10561075 </ComposerToolbarRow>
10571076 ) : null}
10581077 </View>
1059 </KeyboardAvoidingView>
1078 </KeyboardStickyView>
10601079 {settingsSheet}
10611080 </View>
10621081 );
apps/mobile/src/features/threads/PendingUserInputCard.tsx+301-67
......@@ -1,18 +1,64 @@
1import type { ApprovalRequestId } from "@t3tools/contracts";
2import { Pressable, View } from "react-native";
1import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts";
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";
418import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
19import { ControlPill } from "../../components/ControlPill";
520import { 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
828export interface PendingUserInputCardProps {
929 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;
1056 readonly drafts: Record<string, PendingUserInputDraftAnswer>;
11 readonly answers: Record<string, string> | null;
57 readonly answers: Record<string, string | ReadonlyArray<string>> | null;
1258 readonly respondingUserInputId: ApprovalRequestId | null;
1359 readonly onSelectOption: (
1460 requestId: ApprovalRequestId,
15 questionId: string,
61 question: UserInputQuestion,
1662 label: string,
1763 ) => void;
1864 readonly onChangeCustomAnswer: (
......@@ -23,74 +69,242 @@ export interface PendingUserInputCardProps {
2369 readonly onSubmit: () => Promise<unknown>;
2470}
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
2689export function PendingUserInputCard(props: PendingUserInputCardProps) {
27 // The surface is opaque on purpose: the card floats over the thread feed
28 // with no blur behind it, so a translucent background renders the questions
29 // on top of whatever message happens to sit underneath.
30 return (
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">
32 <Text className="font-t3-bold text-2xs uppercase tracking-[1.1px] text-sky-700 dark:text-sky-300">
33 User input needed
34 </Text>
35 <Text className="font-t3-bold text-lg text-neutral-950 dark:text-neutral-50">
36 Fill in the pending answers
37 </Text>
38 {props.pendingUserInput.questions.map((question) => {
39 const draft = props.drafts[question.id];
40 return (
41 <View key={question.id} className="gap-2 pt-1">
42 <Text className="font-t3-bold text-xs uppercase tracking-[1px] text-neutral-500 dark:text-neutral-500">
43 {question.header}
44 </Text>
45 <Text className="font-sans text-base leading-snug text-neutral-950 dark:text-neutral-50">
46 {question.question}
47 </Text>
48 <View className="flex-row flex-wrap gap-2.5">
49 {question.options.map((option) => {
50 const selected =
51 draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length;
52 return (
53 <Pressable
54 key={option.label}
55 className={cn(
56 "rounded-full border px-3 py-2.5 ",
57 selected
58 ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"
59 : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70",
60 )}
61 onPress={() =>
62 props.onSelectOption(
63 props.pendingUserInput.requestId,
64 question.id,
65 option.label,
66 )
67 }
68 >
69 <Text
90 const iconSubtle = useThemeColor("--color-icon-subtle");
91 const questionCount = props.pendingUserInput.questions.length;
92
93 const cardCoverage = props.cardCoverage;
94 const barHeightRef = useRef(0);
95 const cardHeightRef = useRef(0);
96 // Measured card height, written straight from onLayout: the collapse slide
97 // distance. Not animated — it only changes on discrete relayouts.
98 const cardHeight = useSharedValue(0);
99 const notifyCoverage = useCallback(() => {
100 if (!cardCoverage) {
101 return;
102 }
103 const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current);
104 if (coverage === cardCoverage.value) {
105 return;
106 }
107 if (cardCoverage.value === 0) {
108 // First measurement lands while the list is doing its initial
109 // end-pin (thread opened onto a pending request); animating it from
110 // zero would move the end anchor out from under that scroll.
111 cardCoverage.value = coverage;
112 return;
113 }
114 // Animated so a coverage change at rest (discrete max-height
115 // corrections) glides the feed instead of stepping it; toggle timing is
116 // owned by the host's progress values.
117 cardCoverage.value = withTiming(coverage, {
118 duration: USER_INPUT_TOGGLE_DURATION_MS,
119 easing: Easing.out(Easing.cubic),
120 });
121 }, [cardCoverage]);
122 const handleBarLayout = useCallback(
123 (event: LayoutChangeEvent) => {
124 barHeightRef.current = event.nativeEvent.layout.height;
125 notifyCoverage();
126 },
127 [notifyCoverage],
128 );
129 const handleCardLayout = useCallback(
130 (event: LayoutChangeEvent) => {
131 cardHeightRef.current = event.nativeEvent.layout.height;
132 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}
70266 className={cn(
71 "font-t3-bold text-sm",
267 "rounded-full border px-3 py-2.5 ",
72268 selected
73 ? "text-sky-700 dark:text-sky-300"
74 : "text-neutral-600 dark:text-neutral-300",
269 ? "border-blue-300/50 bg-blue-50 dark:border-blue-400/28 dark:bg-blue-400/14"
270 : "border-neutral-200 bg-white dark:border-white/6 dark:bg-neutral-950/70",
75271 )}
272 onPress={() =>
273 props.onSelectOption(
274 props.pendingUserInput.requestId,
275 question,
276 option.label,
277 )
278 }
76279 >
77 {option.label}
78 </Text>
79 </Pressable>
80 );
81 })}
280 <Text
281 className={cn(
282 "font-t3-bold text-sm",
283 selected
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 />
82304 </View>
83 <TextInput
84 value={draft?.customAnswer ?? ""}
85 onChangeText={(value) =>
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 })}
305 );
306 })}
307 </ScrollView>
94308 <Pressable
95309 className={cn(
96310 "items-center justify-center rounded-2xl px-4 py-3.5",
......@@ -103,6 +317,26 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
103317 >
104318 <Text className="font-t3-extrabold text-sm text-white">Submit answers</Text>
105319 </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 )}
106340 </View>
107341 );
108342}
apps/mobile/src/features/threads/ThreadComposer.tsx+94-13
......@@ -14,6 +14,7 @@ import {
1414 serializeComposerFileLink,
1515 type ComposerTrigger,
1616} from "@t3tools/shared/composerTrigger";
17import * as Haptics from "expo-haptics";
1718import type { ReactNode } from "react";
1819import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
1920import {
......@@ -51,7 +52,7 @@ import {
5152 ComposerToolbarScroller,
5253 ComposerToolbarTrigger,
5354} from "../../components/ComposerToolbarTrigger";
54import { ControlPill } from "../../components/ControlPill";
55import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
5556import { ProviderIcon } from "../../components/ProviderIcon";
5657import type { DraftComposerImageAttachment } from "../../lib/composerImages";
5758import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
......@@ -62,9 +63,13 @@ import {
6263 normalizeSearchQuery,
6364 scoreQueryMatch,
6465} from "@t3tools/shared/searchRanking";
65import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
66import {
67 applyProviderOptionSelection,
68 resolveProviderOptionDescriptors,
69} from "../../lib/providerOptions";
6670import { useComposerPathSearch } from "../../state/use-composer-path-search";
6771import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
72import { buildThreadSettingsMenu } from "./thread-settings-menu";
6873import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet";
6974import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation";
7075
......@@ -113,6 +118,8 @@ export interface ThreadComposerProps {
113118 readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void;
114119 readonly onReconnectEnvironment: () => void;
115120 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;
116123}
117124
118125/**
......@@ -307,13 +314,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
307314 }
308315 }, [inputRef]);
309316
317 const onEditorFocusChange = props.onEditorFocusChange;
310318 const handleFocus = useCallback(() => {
311319 setIsFocused(true);
312 }, []);
320 onEditorFocusChange?.(true);
321 }, [onEditorFocusChange]);
313322
314323 const handleBlur = useCallback(() => {
315324 setIsFocused(false);
316 }, []);
325 onEditorFocusChange?.(false);
326 }, [onEditorFocusChange]);
317327 const showStopAction =
318328 props.selectedThread.session?.status === "running" ||
319329 props.selectedThread.session?.status === "starting";
......@@ -623,6 +633,61 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
623633 interactionMode: currentInteractionMode,
624634 });
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
626691 return (
627692 <Animated.View
628693 className="px-4"
......@@ -792,15 +857,31 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
792857 onPress={() => void props.onPickDraftImages()}
793858 showChevron={false}
794859 />
795 <ComposerToolbarTrigger
796 accessibilityLabel="Thread settings"
797 iconNode={
798 <ProviderIcon provider={currentModelOption?.providerDriver} size={16} />
799 }
800 label={settingsSummaryLabel}
801 maxWidth={320}
802 onPress={settingsSheetPresentation.open}
803 />
860 {settingsMenu ? (
861 <ControlPillMenu
862 actions={settingsMenu.actions}
863 onPressAction={({ nativeEvent }) => handleSettingsMenuAction(nativeEvent.event)}
864 >
865 <ComposerToolbarTrigger
866 accessibilityLabel="Thread settings"
867 iconNode={
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 )}
804885 {showStopAction ? (
805886 <ComposerToolbarButton
806887 accessibilityLabel="Stop"
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+328-40
......@@ -2,6 +2,7 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect
22import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads";
33import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard";
44import type { LegendListRef } from "@legendapp/list/react-native";
5import { HeaderHeightContext } from "@react-navigation/elements";
56import type {
67 ApprovalRequestId,
78 EnvironmentId,
......@@ -13,18 +14,45 @@ import type {
1314 RuntimeMode,
1415 ServerConfig as T3ServerConfig,
1516 ThreadId,
17 UserInputQuestion,
1618} from "@t3tools/contracts";
1719import * as Haptics from "expo-haptics";
18import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
19import { Platform, View, type GestureResponderEvent } from "react-native";
20import {
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";
2040import {
2141 KeyboardController,
2242 KeyboardStickyView,
2343 useKeyboardState,
2444} 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";
2653import { useSafeAreaInsets } from "react-native-safe-area-context";
2754
55import { ControlPill } from "../../components/ControlPill";
2856import type { ComposerEditorHandle } from "../../components/ComposerEditor";
2957import type { StatusTone } from "../../components/StatusPill";
3058import type { DraftComposerImageAttachment } from "../../lib/composerImages";
......@@ -38,6 +66,11 @@ import type {
3866} from "../../lib/threadActivity";
3967import { PendingApprovalCard } from "./PendingApprovalCard";
4068import { PendingUserInputCard } from "./PendingUserInputCard";
69import {
70 derivePendingUserInputMaxHeight,
71 ESTIMATED_KEYBOARD_HEIGHT,
72 USER_INPUT_TOGGLE_DURATION_MS,
73} from "./pendingUserInputLayout";
4174import {
4275 COMPOSER_COLLAPSED_CHROME,
4376 COMPOSER_EXPANDED_CHROME,
......@@ -58,7 +91,7 @@ export interface ThreadDetailScreenProps {
5891 readonly respondingApprovalId: ApprovalRequestId | null;
5992 readonly activePendingUserInput: PendingUserInput | null;
6093 readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
61 readonly activePendingUserInputAnswers: Record<string, string> | null;
94 readonly activePendingUserInputAnswers: Record<string, string | ReadonlyArray<string>> | null;
6295 readonly respondingUserInputId: ApprovalRequestId | null;
6396 readonly draftMessage: string;
6497 readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
......@@ -93,7 +126,7 @@ export interface ThreadDetailScreenProps {
93126 ) => Promise<unknown>;
94127 readonly onSelectUserInputOption: (
95128 requestId: ApprovalRequestId,
96 questionId: string,
129 question: UserInputQuestion,
97130 label: string,
98131 ) => void;
99132 readonly onChangeUserInputCustomAnswer: (
......@@ -175,8 +208,48 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE
175208 }, [threadId, feed]);
176209}
177210
211const USER_INPUT_TOGGLE_TIMING = {
212 duration: USER_INPUT_TOGGLE_DURATION_MS,
213 easing: Easing.out(Easing.cubic),
214};
215
178216export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
179217 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;
180253 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
181254 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
182255 const composerEditorRef = useRef<ComposerEditorHandle>(null);
......@@ -187,11 +260,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
187260 const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);
188261 const [composerExpanded, setComposerExpanded] = useState(false);
189262 const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
190 // Key the safe-area padding on keyboard visibility, not focus: on Android
191 // the back gesture closes the keyboard while the editor stays focused, and
192 // a focus-keyed inset would leave the toolbar under the gesture bar.
193 const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
194 const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12);
263 const [endFollowEnabled, setEndFollowEnabled] = useState(true);
264 // Android keys the safe-area padding on keyboard visibility (#5988): the
265 // back gesture closes the keyboard while the editor stays focused, and a
266 // focus-keyed inset would leave the toolbar under the gesture bar. iOS must
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);
195274 const contentPresentationKind = props.contentPresentation.kind;
196275 // The raw sync status enters "synchronizing" on every full fetch, cached or
197276 // not. Whether messages are already on screen decides the pill label: no
......@@ -212,6 +291,41 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
212291 const selectedThreadFeed = props.selectedThreadFeed;
213292 const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
214293 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 });
215329 const estimatedOverlayHeight = composerOverlapHeight;
216330 // The overlay's measured height includes the home-indicator inset (the
217331 // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
......@@ -228,7 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
228342 Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
229343 -nativeInsetOvercount,
230344 );
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 );
231373 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]);
232442 const showContent = props.showContent ?? true;
233443 const layoutVariant = props.layoutVariant ?? "compact";
234444 const isSplitLayout = layoutVariant === "split";
......@@ -249,6 +459,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
249459 useEffect(() => {
250460 setAnchorMessageId(null);
251461 lastScrolledAnchorMessageIdRef.current = null;
462 setEndFollowEnabled(true);
252463 freeze.set(false);
253464 }, [freeze, selectedThreadKey]);
254465
......@@ -320,6 +531,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
320531 composerEditorRef.current?.blur();
321532 }, []);
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
323544 const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => {
324545 feedTouchStartRef.current = {
325546 pageX: event.nativeEvent.pageX,
......@@ -373,13 +594,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
373594 listRef={listRef}
374595 freeze={freeze}
375596 anchorMessageId={anchorMessageId}
376 contentInsetEndAdjustment={contentInsetEndAdjustment}
597 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
377598 contentTopInset={0}
378599 contentBottomInset={estimatedOverlayHeight}
379600 contentMaxWidth={contentMaxWidth}
380601 layoutVariant={layoutVariant}
381602 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
382603 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
604 onEndFollowEnabledChange={setEndFollowEnabled}
383605 skills={selectedProviderSkills}
384606 loadEarlier={props.loadEarlier ?? null}
385607 />
......@@ -391,6 +613,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
391613 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
392614 {showContent ? (
393615 <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}
394620 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
395621 offset={{ closed: 0, opened: 0 }}
396622 >
......@@ -398,10 +624,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
398624 list's bottom inset, so any padding above the pill/composer
399625 pushes the resting content floor up by the same amount. */}
400626 <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}
401670 <View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
402671 {props.activePendingApproval || props.activePendingUserInput ? (
403672 <Animated.View
404673 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 }
405681 entering={FadeInDown.duration(220)}
406682 exiting={FadeOut.duration(140)}
407683 >
......@@ -415,6 +691,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
415691 {props.activePendingUserInput ? (
416692 <PendingUserInputCard
417693 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}
418701 drafts={props.activePendingUserInputDrafts}
419702 answers={props.activePendingUserInputAnswers}
420703 respondingUserInputId={props.respondingUserInputId}
......@@ -427,35 +710,40 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
427710 ) : null}
428711 </View>
429712
430 <ThreadComposer
431 editorRef={composerEditorRef}
432 draftMessage={props.draftMessage}
433 draftAttachments={props.draftAttachments}
434 placeholder="Ask the repo agent, or run a command…"
435 contentMaxWidth={contentMaxWidth}
436 connectionState={props.connectionStateLabel}
437 connectionError={props.connectionError}
438 environmentLabel={props.environmentLabel}
439 threadSyncPhase={threadSyncPhase}
440 selectedThread={props.selectedThread}
441 serverConfig={props.serverConfig}
442 queueCount={props.selectedThreadQueueCount}
443 activeThreadBusy={props.activeThreadBusy}
444 environmentId={props.environmentId}
445 projectCwd={props.projectWorkspaceRoot}
446 bottomInset={composerBottomInset}
447 onChangeDraftMessage={props.onChangeDraftMessage}
448 onPickDraftImages={props.onPickDraftImages}
449 onNativePasteImages={props.onNativePasteImages}
450 onRemoveDraftImage={props.onRemoveDraftImage}
451 onStopThread={props.onStopThread}
452 onSendMessage={handleSendMessage}
453 onReconnectEnvironment={props.onReconnectEnvironment}
454 onUpdateModelSelection={props.onUpdateThreadModelSelection}
455 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
456 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
457 onExpandedChange={setComposerExpanded}
458 />
713 {/* Hidden (not unmounted) while a user-input request owns the
714 composer slot, so composer drafts and editor state survive. */}
715 <View style={activeUserInputRequestId !== null ? { display: "none" } : undefined}>
716 <ThreadComposer
717 editorRef={composerEditorRef}
718 draftMessage={props.draftMessage}
719 draftAttachments={props.draftAttachments}
720 placeholder="Ask the repo agent, or run a command…"
721 contentMaxWidth={contentMaxWidth}
722 connectionState={props.connectionStateLabel}
723 connectionError={props.connectionError}
724 environmentLabel={props.environmentLabel}
725 threadSyncPhase={threadSyncPhase}
726 selectedThread={props.selectedThread}
727 serverConfig={props.serverConfig}
728 queueCount={props.selectedThreadQueueCount}
729 activeThreadBusy={props.activeThreadBusy}
730 environmentId={props.environmentId}
731 projectCwd={props.projectWorkspaceRoot}
732 bottomInset={composerBottomInset}
733 onChangeDraftMessage={props.onChangeDraftMessage}
734 onPickDraftImages={props.onPickDraftImages}
735 onNativePasteImages={props.onNativePasteImages}
736 onRemoveDraftImage={props.onRemoveDraftImage}
737 onStopThread={props.onStopThread}
738 onSendMessage={handleSendMessage}
739 onReconnectEnvironment={props.onReconnectEnvironment}
740 onUpdateModelSelection={props.onUpdateThreadModelSelection}
741 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
742 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
743 onExpandedChange={setComposerExpanded}
744 onEditorFocusChange={handleOwnedInputFocusChange}
745 />
746 </View>
459747 </View>
460748 </KeyboardStickyView>
461749 ) : null}
apps/mobile/src/features/threads/ThreadFeed.tsx+94-37
......@@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
4848import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated";
4949import { useThemeColor } from "../../lib/useThemeColor";
5050import { useFontFamily } from "../../lib/useFontFamily";
51import { scopedThreadKey } from "../../lib/scopedEntities";
5152import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
5253import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
5354import {
......@@ -89,6 +90,10 @@ import {
8990 type ThreadFeedLatestTurn,
9091} from "../../lib/threadActivity";
9192import type { ThreadContentPresentation } from "./threadContentPresentation";
93import {
94 resolveThreadFeedLiveFollow,
95 type ThreadFeedLiveFollowEvent,
96} from "./thread-feed-live-follow";
9297import {
9398 collapsedWorkLogHeight,
9499 ThreadWorkGroupToggle,
......@@ -149,6 +154,7 @@ export interface ThreadFeedProps {
149154 readonly layoutVariant?: LayoutVariant;
150155 readonly usesAutomaticContentInsets?: boolean;
151156 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
157 readonly onEndFollowEnabledChange?: (enabled: boolean) => void;
152158 readonly skills?: ReadonlyArray<SelectableMarkdownSkill>;
153159 /** Non-null when older turns exist beyond the loaded window. */
154160 readonly loadEarlier?: {
......@@ -1324,6 +1330,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13241330 const disclosureAnchorKeyRef = useRef<string | null>(null);
13251331 const headerMaterialVisibleRef = useRef(false);
13261332 const previousLatestTurnRef = useRef(props.latestTurn);
1333 const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
13271334 const { width: windowWidth } = useWindowDimensions();
13281335 const { appearance } = useAppearancePreferences();
13291336 const [viewportWidth, setViewportWidth] = useState(() =>
......@@ -1342,13 +1349,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13421349 // momentum; only motion inside a session can break follow, so MVCP
13431350 // compensations and programmatic scrolls never strand a follower.
13441351 const userScrollSessionRef = useRef(false);
1345 const setEndFollow = useCallback((enabled: boolean) => {
1346 if (endFollowEnabledRef.current === enabled) {
1347 return;
1348 }
1349 endFollowEnabledRef.current = enabled;
1350 setEndFollowEnabled(enabled);
1351 }, []);
1352 const setEndFollow = useCallback(
1353 (enabled: boolean) => {
1354 if (endFollowEnabledRef.current === enabled) {
1355 return;
1356 }
1357 endFollowEnabledRef.current = enabled;
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 );
13521369 const [interactionState, setInteractionState] = useState<{
13531370 readonly copiedRowId: string | null;
13541371 readonly expandedWorkGroups: Record<string, boolean>;
......@@ -1460,40 +1477,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14601477 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
14611478 // header height back or the material toggles a full header too late.
14621479 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
1463 // Latch bookkeeping. LegendList recomputes its inset-aware end distance
1464 // before invoking this handler, so getState() is current. Returning to
1465 // the end re-arms follow no matter who scrolled (the user, or our own
1466 // scroll-to-end); moving away breaks it only during a user-initiated
1467 // scroll session, so MVCP compensations and programmatic repositioning
1468 // can never strand a follower.
1480 // LegendList recomputes its inset-aware end distance before invoking
1481 // this handler, so getState() is current. Only the actual end re-arms
1482 // follow: its broader maintain-scroll threshold is large enough for a
1483 // streaming chunk to pull a user back before their upward drag escapes.
1484 // A live user-scroll session still wins even if the first scroll event
1485 // remains inside LegendList's at-end tolerance.
14691486 const listState = props.listRef.current?.getState();
14701487 if (listState) {
1471 if (listState.isWithinMaintainScrollAtEndThreshold) {
1472 setEndFollow(true);
1473 } else if (userScrollSessionRef.current) {
1474 setEndFollow(false);
1475 }
1488 transitionEndFollow({
1489 type: "scroll",
1490 isAtEnd: listState.isAtEnd,
1491 userScrollSessionActive: userScrollSessionRef.current,
1492 });
14761493 }
14771494 },
1478 [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, setEndFollow],
1495 [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow],
14791496 );
1497 const clearUserScrollSettle = useCallback(() => {
1498 if (userScrollSettleTimerRef.current !== null) {
1499 clearTimeout(userScrollSettleTimerRef.current);
1500 userScrollSettleTimerRef.current = null;
1501 }
1502 }, []);
14801503 const handleScrollBeginDrag = useCallback(() => {
1504 clearUserScrollSettle();
14811505 userScrollSessionRef.current = true;
1482 }, []);
1483 // The session must survive past finger-lift so momentum that carries the
1484 // user away from the end still breaks follow; a drag released with no
1485 // momentum ends its session at the release itself, otherwise at momentum
1486 // end. Leaving a session open would let a later animated maintain-scroll
1487 // read as user motion and break follow spuriously.
1488 const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
1489 const velocity = event.nativeEvent.velocity?.y ?? 0;
1490 if (Math.abs(velocity) < 0.05) {
1506 // Pause before the first scroll event. Otherwise a stream update can run
1507 // maintainScrollAtEnd between touch-down and the drag leaving its threshold.
1508 transitionEndFollow({ type: "user-scroll-begin" });
1509 }, [clearUserScrollSettle, transitionEndFollow]);
1510 const finishUserScroll = useCallback(
1511 (releaseIsAtEnd?: boolean) => {
1512 clearUserScrollSettle();
1513 const userScrollSessionActive = userScrollSessionRef.current;
14911514 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();
14921539 }
1493 }, []);
1540 }, [clearUserScrollSettle]);
14941541 const handleMomentumScrollEnd = useCallback(() => {
1495 userScrollSessionRef.current = false;
1496 }, []);
1542 finishUserScroll();
1543 }, [finishUserScroll]);
1544
1545 useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]);
14971546
14981547 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
14991548 const nextWidth = Math.round(event.nativeEvent.layout.width);
......@@ -1502,23 +1551,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
15021551 setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
15031552 }, []);
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
15051559 useEffect(() => {
15061560 reportHeaderMaterialVisibility(false);
1507 }, [props.threadId, reportHeaderMaterialVisibility]);
1561 }, [feedThreadKey, reportHeaderMaterialVisibility]);
15081562
15091563 // A thread switch opens pinned to the end; a send explicitly returns to the
15101564 // live edge (ThreadDetailScreen scrolls the new message into place). Both
15111565 // re-arm follow regardless of where the user had scrolled before.
15121566 useEffect(() => {
1567 clearUserScrollSettle();
15131568 userScrollSessionRef.current = false;
1514 setEndFollow(true);
1515 }, [props.threadId, setEndFollow]);
1569 transitionEndFollow({ type: "reset" });
1570 }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]);
15161571 useEffect(() => {
15171572 if (props.anchorMessageId !== null) {
1573 clearUserScrollSettle();
15181574 userScrollSessionRef.current = false;
1519 setEndFollow(true);
1575 transitionEndFollow({ type: "reset" });
15201576 }
1521 }, [props.anchorMessageId, setEndFollow]);
1577 }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]);
15221578
15231579 const expandedWorkGroupIds = useMemo(() => {
15241580 const ids = new Set<string>();
......@@ -1554,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
15541610 // initial scroll-to-end computes with a zero end inset and rests one
15551611 // composer-height short of the end. Layout effect: it must land before the
15561612 // 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"}`;
15581614 useLayoutEffect(() => {
15591615 const bottom = props.contentInsetEndAdjustment.value;
15601616 if (bottom > 0) {
......@@ -1921,6 +1977,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19211977 onScroll={handleScroll}
19221978 onScrollBeginDrag={handleScrollBeginDrag}
19231979 onScrollEndDrag={handleScrollEndDrag}
1980 onMomentumScrollBegin={handleMomentumScrollBegin}
19241981 onMomentumScrollEnd={handleMomentumScrollEnd}
19251982 scrollEventThrottle={16}
19261983 ListHeaderComponent={
apps/mobile/src/features/threads/ThreadSettingsSheet.tsx+1-27
......@@ -30,6 +30,7 @@ import { cn } from "../../lib/cn";
3030import type { ModelOption, ProviderGroup } from "../../lib/modelOptions";
3131import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions";
3232import { useThemeColor } from "../../lib/useThemeColor";
33import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu";
3334import { pendingModelAfterPress } from "./thread-settings-sheet-state";
3435import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation";
3536
......@@ -40,26 +41,6 @@ import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet
4041 */
4142const 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
6344/**
6445 * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill,
6546 * covering model, provider options, runtime mode, and plan mode in one label.
......@@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: {
7960 ].join(" · ");
8061}
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
8963function ModelRow(props: {
9064 readonly option: ModelOption;
9165 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
3232}
3333
3434describe("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
3548 it("downloads and restarts when a new update is available", async () => {
3649 const client = makeUpdateClient({
3750 checkForUpdateAsync: vi.fn(async () => ({
......@@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => {
100113 reportError.mockRestore();
101114 });
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
103142 it("coalesces overlapping launch and manual checks", async () => {
104143 let resolveCheck!: (result: {
105144 readonly isAvailable: boolean;
apps/mobile/src/features/updates/app-updates.ts+20-3
......@@ -48,8 +48,17 @@ interface Deferred {
4848}
4949
5050const 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]);
5155let 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
5362/**
5463 * Keeps the manual update affordance discoverable only to someone deliberately
5564 * tapping the version row five times.
......@@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): {
7382
7483export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> {
7584 const client = options.client ?? Updates;
76 if (!client.isEnabled) return;
85 if (!isAppUpdateCheckAvailable(client)) return;
7786
7887 if (appUpdateCheckInFlight) {
7988 await observeAppUpdateCheck(appUpdateCheckInFlight, options);
......@@ -207,19 +216,27 @@ function reportUpdateFailure(
207216 fallback: string,
208217 onFailure: AppUpdateCheckOptions["onFailure"],
209218): void {
210 reportAtomCommandResult(result, { label: "app update check" });
211219 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
212220 const error = squashAtomCommandFailure(result);
221 if (isAppUpdateUnavailableError(error)) return;
222
223 reportAtomCommandResult(result, { label: "app update check" });
213224 onFailure?.(error instanceof Error ? error.message : fallback);
214225}
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
216233export function createAppUpdateLaunchCheck(
217234 client: AppUpdateClient = Updates,
218235): () => Promise<void> | undefined {
219236 let started = false;
220237
221238 return () => {
222 if (started || !client.isEnabled) return undefined;
239 if (started || !isAppUpdateCheckAvailable(client)) return undefined;
223240 started = true;
224241 return runAppUpdateCheck({ client });
225242 };
apps/mobile/src/lib/threadActivity.test.ts+95
......@@ -12,12 +12,107 @@ import {
1212} from "@t3tools/contracts";
1313
1414import {
15 buildPendingUserInputAnswers,
1516 buildThreadFeed,
1617 deriveThreadFeedPresentation,
18 isPendingUserInputOptionSelected,
19 setPendingUserInputCustomAnswer,
20 togglePendingUserInputOptionSelection,
1721 type ThreadFeedActivity,
1822 type ThreadFeedEntry,
1923} 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
21116function makeActivity(
22117 input: Partial<OrchestrationThreadActivity> &
23118 Pick<OrchestrationThreadActivity, "id" | "kind" | "summary" | "createdAt">,
apps/mobile/src/lib/threadActivity.ts+67-9
......@@ -26,7 +26,7 @@ export interface PendingUserInput {
2626}
2727
2828export interface PendingUserInputDraftAnswer {
29 readonly selectedOptionLabel?: string;
29 readonly selectedOptionLabels?: ReadonlyArray<string>;
3030 readonly customAnswer?: string;
3131}
3232
......@@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null {
227227 return trimmed.length > 0 ? trimmed : null;
228228}
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
230242function resolvePendingUserInputAnswer(
243 question: UserInputQuestion,
231244 draft: PendingUserInputDraftAnswer | undefined,
232): string | null {
245): string | ReadonlyArray<string> | null {
233246 const customAnswer = normalizeDraftAnswer(draft?.customAnswer);
234247 if (customAnswer) {
235248 return customAnswer;
236249 }
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;
238256}
239257
240258/** Codex children settle via task.updated (idle/failed/interrupted), never
......@@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer(
14281446 draft: PendingUserInputDraftAnswer | undefined,
14291447 customAnswer: string,
14301448): PendingUserInputDraftAnswer {
1431 const selectedOptionLabel =
1432 customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel;
1449 const selectedOptionLabels =
1450 customAnswer.trim().length > 0
1451 ? undefined
1452 : normalizeSelectedOptionLabels(draft?.selectedOptionLabels);
14331453 return {
14341454 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],
14361494 };
14371495}
14381496
14391497export function buildPendingUserInputAnswers(
14401498 questions: ReadonlyArray<UserInputQuestion>,
14411499 draftAnswers: Record<string, PendingUserInputDraftAnswer>,
1442): Record<string, string> | null {
1443 const answers: Record<string, string> = {};
1500): Record<string, string | ReadonlyArray<string>> | null {
1501 const answers: Record<string, string | ReadonlyArray<string>> = {};
14441502
14451503 for (const question of questions) {
1446 const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]);
1504 const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]);
14471505 if (!answer) {
14481506 return null;
14491507 }
apps/mobile/src/state/use-selected-thread-requests.ts+18-7
......@@ -1,7 +1,11 @@
11import { useAtomValue } from "@effect/atom-react";
22import { 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";
59import { Atom } from "effect/unstable/reactivity";
610
711import { threadEnvironment } from "../state/threads";
......@@ -12,6 +16,7 @@ import {
1216 derivePendingUserInputs,
1317 setPendingUserInputCustomAnswer,
1418 sortThreadActivities,
19 togglePendingUserInputOptionSelection,
1520 type PendingUserInputDraftAnswer,
1621} from "../lib/threadActivity";
1722import { appAtomRegistry } from "./atom-registry";
......@@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make<
2328 Record<string, Record<string, PendingUserInputDraftAnswer>>
2429>({}).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 {
2736 const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
2837 appAtomRegistry.set(userInputDraftsByRequestKeyAtom, {
2938 ...current,
3039 [requestKey]: {
3140 ...current[requestKey],
32 [questionId]: {
33 selectedOptionLabel: label,
34 },
41 [question.id]: togglePendingUserInputOptionSelection(
42 question,
43 current[requestKey]?.[question.id],
44 label,
45 ),
3546 },
3647 });
3748}
......@@ -97,13 +108,13 @@ export function useSelectedThreadRequests() {
97108 : null;
98109
99110 const onSelectUserInputOption = useCallback(
100 (requestId: ApprovalRequestId, questionId: string, label: string) => {
111 (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => {
101112 if (!selectedThreadShell) {
102113 return;
103114 }
104115
105116 const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
106 setUserInputDraftOption(requestKey, questionId, label);
117 setUserInputDraftOption(requestKey, question, label);
107118 },
108119 [selectedThreadShell],
109120 );
apps/web/package.json+1-1
......@@ -21,7 +21,7 @@
2121 "@dnd-kit/utilities": "^3.2.2",
2222 "@effect/atom-react": "catalog:",
2323 "@formkit/auto-animate": "^0.9.0",
24 "@legendapp/list": "3.3.3",
24 "@legendapp/list": "catalog:",
2525 "@lexical/react": "^0.41.0",
2626 "@pierre/diffs": "catalog:",
2727 "@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 @@
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 };
151diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift
2index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644
52index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644
353--- a/ios/Shared/MenuViewImplementation.swift
454+++ 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 }
693
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+
7115 self.menu = menu
8116 self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress
9117+ // In long-press mode the button must not intercept touches: as a
......@@ -17,6 +125,50 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7
17125+ self.updateLongPressInteraction()
18126+ }
19127+
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+
20172+ private var longPressInteraction: UIContextMenuInteraction?
21173+
22174+ public override func didMoveToSuperview() {
......@@ -44,3 +196,74 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7
44196 }
45197
46198 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
140140
141141 NS_ASSUME_NONNULL_END
142142diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm
143index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644
143index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb2199dcbd58 100644
144144--- a/ios/RNSScreenStackHeaderConfig.mm
145145+++ 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
147154 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;
148161 static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34;
149162
150163+static NSInteger navigationItemStyleFromCppEquivalent(
......@@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
164177 @interface RCTImageLoader (Private)
165178 - (id<RCTImageCache>)imageCache;
166179 @end
167@@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string
180@@ -47,6 +69,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string
168181 @end
169182
170183 @interface RNSScreenStackHeaderConfig () <RCTMountingTransactionObserving>
......@@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
174187 @end
175188
176189 @implementation RNSScreenStackHeaderConfig {
177@@ -81,6 +98,7 @@ - (void)initProps
190@@ -81,6 +106,7 @@ - (void)initProps
178191 self.hidden = YES;
179192 _reactSubviews = [NSMutableArray new];
180193 _backTitleVisible = YES;
......@@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
182195 _blurEffect = RNSBlurEffectStyleNone;
183196 }
184197
185@@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc
198@@ -496,6 +522,10 @@ + (void)updateViewController:(UIViewController *)vc
186199
187200 if (shouldHide) {
188201 navitem.title = config.title;
......@@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
193206
194207 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items.
195208 [navctr setNavigationBarHidden:YES animated:animated];
196@@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc
209@@ -512,11 +542,19 @@ + (void)updateViewController:(UIViewController *)vc
197210 }
198211 navitem.largeTitleDisplayMode =
199212 config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever;
......@@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
213226
214227 // appearance does not apply to the tvOS so we need to use lagacy customization
215228 #if TARGET_OS_TV
216@@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc
229@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc
217230 // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug).
218231 // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments)
219232 navitem.title = config.title;
......@@ -221,32 +234,62 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
221234- withCurrentItems:navitem.leftBarButtonItems];
222235- navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
223236- withCurrentItems:navitem.rightBarButtonItems];
224+ NSArray<UIBarButtonItem *> *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems
225+ withCurrentItems:navitem.leftBarButtonItems
226+ navigationItem:navitem];
227+ NSArray<UIBarButtonItem *> *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems
228+ withCurrentItems:navitem.rightBarButtonItems
229+ navigationItem:navitem];
230+ NSArray<UIBarButtonItem *> *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems
231+ withCurrentItems:@[]
237+ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[];
238+ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[];
239+ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[];
240+ NSArray<UIBarButtonItem *> *subviewLeftItems = navitem.leftBarButtonItems ?: @[];
241+ NSArray<UIBarButtonItem *> *subviewRightItems = navitem.rightBarButtonItems ?: @[];
242+ // The key includes the config instance's identity: cached items capture
243+ // this config's event emitter in their press handlers, so a remounted
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
232260+ 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];
233267+#if !TARGET_OS_TV
234+ if (@available(iOS 16.0, *)) {
235+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems];
236+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems];
237+ if (@available(iOS 26.0, *)) {
238+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems];
268+ if (@available(iOS 16.0, *)) {
269+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems];
270+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems];
271+ if (@available(iOS 26.0, *)) {
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;
239279+ }
240+ navitem.leftBarButtonItems = nil;
241+ navitem.rightBarButtonItems = nil;
242+ } else {
280+#else
243281+ navitem.leftBarButtonItems = leftBarButtonItems;
244282+ navitem.rightBarButtonItems = rightBarButtonItems;
245+ }
246+#else
247+ navitem.leftBarButtonItems = leftBarButtonItems;
248+ navitem.rightBarButtonItems = rightBarButtonItems;
249283+#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+ }
250293+ NSDictionary<NSString *, id> *mailSearchToolbarConfig = nil;
251294+ for (NSDictionary<NSString *, id> *toolbarConfig in config.headerToolbarItems) {
252295+ if (toolbarConfig[@"mailSearchToolbar"]) {
......@@ -269,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
269312+ if (existingMailSearchToolbar == nil) {
270313+ existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag];
271314+ }
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+ }
273331+
274+ if (mailSearchToolbarConfig != nil) {
332+ if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) {
275333+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
276334+ if (@available(iOS 26.0, *)) {
277335+ CGFloat horizontalInset = 18.0;
......@@ -304,21 +362,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
304362+
305363+ UIView *toolbarHost = [[UIView alloc] init];
306364+ toolbarHost.tag = RNSMailSearchToolbarViewTag;
365+ objc_setAssociatedObject(
366+ toolbarHost,
367+ &RNSAppliedMailSearchToolbarConfigKey,
368+ mailSearchToolbarKey,
369+ OBJC_ASSOCIATION_RETAIN_NONATOMIC);
307370+ toolbarHost.translatesAutoresizingMaskIntoConstraints = NO;
308371+ [chromeHostView addSubview:toolbarHost];
309+ // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard
310+ // layout guide no longer rests at the bottom safe-area edge while the
311+ // keyboard is hidden, which pushed the toolbar offscreen. The required
312+ // resting position is the safe area; the keyboard guide only pulls the
313+ // toolbar up when it actually tracks a visible keyboard.
372+ // The screen stays mounted beneath pushed routes, so its keyboard layout
373+ // guide can track a keyboard owned by another screen. Keep the toolbar at
374+ // rest unless its own search field is editing.
314375+ NSLayoutConstraint *keyboardAvoidConstraint =
315376+ [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor
316377+ constant:-toolbarBottomSpacing];
317+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh;
378+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow;
318379+ NSLayoutConstraint *restingBottomConstraint =
319380+ [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
320381+ constant:-toolbarBottomSpacing];
321+ restingBottomConstraint.priority = UILayoutPriorityDefaultLow;
382+ restingBottomConstraint.priority = UILayoutPriorityDefaultHigh;
322383+ [NSLayoutConstraint activateConstraints:@[
323384+ [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor],
324385+ [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor
......@@ -330,6 +391,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
330391+ ]];
331392+ [chromeHostView bringSubviewToFront:toolbarHost];
332393+
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+
333428+ UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular];
334429+ glassEffect.interactive = YES;
335430+ UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect];
......@@ -411,6 +506,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
411506+ searchBar.searchTextField.adjustsFontForContentSizeCategory = YES;
412507+ searchBar.searchTextField.textColor = UIColor.labelColor;
413508+ searchBar.searchTextField.tintColor = UIColor.labelColor;
509+ configureKeyboardTracking(searchBar.searchTextField);
414510+ if (placeholder != nil) {
415511+ searchBar.searchTextField.attributedPlaceholder =
416512+ [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes];
......@@ -443,6 +539,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
443539+ searchField.adjustsFontForContentSizeCategory = YES;
444540+ searchField.textColor = UIColor.labelColor;
445541+ searchField.tintColor = UIColor.labelColor;
542+ configureKeyboardTracking(searchField);
446543+ searchField.translatesAutoresizingMaskIntoConstraints = NO;
447544+ [glassView.contentView addSubview:searchField];
448545+ [NSLayoutConstraint activateConstraints:@[
......@@ -491,20 +588,34 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
491588+ navigationToolbarConfigs = @[];
492589+ }
493590+
494+ NSArray<UIBarButtonItem *> *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs
495+ withCurrentItems:@[]
496+ navigationItem:navitem];
497+ if (toolbarItems.count > 0) {
498+ vc.toolbarItems = toolbarItems;
499+ [navctr setToolbarHidden:NO animated:animated];
500+ } else {
501+ vc.toolbarItems = nil;
502+ [navctr setToolbarHidden:YES animated:animated];
591+ NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[];
592+ // Same reuse rule as the header item groups above (including the config
593+ // identity — cached toolbar items capture this config's event emitter).
594+ // The top-view-controller and hidden-state checks scope the skip to
595+ // same-screen refreshes, so transitions between screens with different
596+ // toolbars still reapply.
597+ NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ];
598+ BOOL reuseToolbarItems = navctr.topViewController == vc &&
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);
503614+ }
504615
505616 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items
506617 // (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 *
508619
509620 - (NSArray<UIBarButtonItem *> *)barButtonItemsFromConfigs:(NSArray<NSDictionary<NSString *, id> *> *)dicts
510621 withCurrentItems:(NSArray<UIBarButtonItem *> *)currentItems
......@@ -512,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
512623 {
513624 if (dicts.count == 0) {
514625 return currentItems;
515@@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
626@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController *
516627 [items addObjectsFromArray:currentItems];
517628 for (NSUInteger i = 0; i < dicts.count; i++) {
518629 NSDictionary *dict = dicts[i];
......@@ -700,7 +811,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
700811+ }
701812+ }
702813+#endif
703+ if (index != nil && index.integerValue < items.count) {
814+ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) {
704815+ [items insertObject:item atIndex:index.integerValue];
705816+ } else {
706817+ [items addObject:item];
......@@ -711,7 +822,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
711822 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict
712823 action:^(NSString *buttonId) {
713824 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 {
715833 [items addObject:item];
716834 }
717835 } else if (dict[@"spacing"]) {
......@@ -729,9 +847,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
729847+ item.width = [spacingValue doubleValue];
730848+ }
731849 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) {
733852 [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 *
735856 return items;
736857 }
737858
......@@ -779,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
779900 RNS_IGNORE_SUPER_CALL_BEGIN
780901 - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex
781902 {
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::
783904 }
784905
785906 _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title);
......@@ -788,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
788909 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) {
789910 _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily);
790911 }
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::
792913 _disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
793914 _backButtonDisplayMode =
794915 [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];
......@@ -796,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d
796917
797918 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) {
798919 _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::
800921 _headerRightBarButtonItems = array;
801922 }
802923
pnpm-lock.yaml+33-29
......@@ -12,6 +12,9 @@ catalogs:
1212 '@effect/tsgo':
1313 specifier: 0.13.2
1414 version: 0.13.2
15 '@legendapp/list':
16 specifier: 3.3.5
17 version: 3.3.5
1518 '@noble/curves':
1619 specifier: 1.9.1
1720 version: 1.9.1
......@@ -69,12 +72,13 @@ overrides:
6972packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE=
7073
7174patchedDependencies:
75 '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1
7276 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b
7377 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46
7478 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
75 '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09
79 '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5
7680 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
77 '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae
81 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
7882 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784
7983 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273
8084 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
......@@ -82,7 +86,7 @@ patchedDependencies:
8286 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
8387 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008
8488 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
85 react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8
89 react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e
8690
8791importers:
8892
......@@ -197,7 +201,7 @@ importers:
197201 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)
198202 '@clerk/expo':
199203 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)
201205 '@effect/atom-react':
202206 specifier: 4.0.0-beta.103
203207 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:
211215 specifier: ~56.0.18
212216 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)
213217 '@legendapp/list':
214 specifier: 3.3.3
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)
218 specifier: 'catalog:'
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)
216220 '@noble/curves':
217221 specifier: 'catalog:'
218222 version: 1.9.1
......@@ -224,7 +228,7 @@ importers:
224228 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)
225229 '@react-native-menu/menu':
226230 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)
228232 '@react-navigation/elements':
229233 specifier: 2.9.26
230234 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)
......@@ -233,7 +237,7 @@ importers:
233237 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)
234238 '@react-navigation/native-stack':
235239 specifier: 7.17.6
236 version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)
240 version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)
237241 '@shikijs/core':
238242 specifier: 4.2.0
239243 version: 4.2.0
......@@ -398,7 +402,7 @@ importers:
398402 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)
399403 react-native-screens:
400404 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)
402406 react-native-shiki-engine:
403407 specifier: ^0.3.12
404408 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:
537541 specifier: ^0.9.0
538542 version: 0.9.0
539543 '@legendapp/list':
540 specifier: 3.3.3
541 version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
544 specifier: 'catalog:'
545 version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
542546 '@lexical/react':
543547 specifier: ^0.41.0
544548 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:
29502954 resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}
29512955 engines: {node: '>=12'}
29522956
2953 '@legendapp/list@3.3.3':
2954 resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==}
2957 '@legendapp/list@3.3.5':
2958 resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==}
29552959 peerDependencies:
29562960 react: '*'
29572961 react-dom: '*'
......@@ -11584,7 +11588,7 @@ snapshots:
1158411588 electron-store: 8.2.0
1158511589 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)':
1158811592 dependencies:
1158911593 '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
1159011594 '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
......@@ -12253,7 +12257,7 @@ snapshots:
1225312257 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1225412258 zod: 3.25.76
1225512259 optionalDependencies:
12256 expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7)
12260 expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8)
1225712261 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)
1225812262 transitivePeerDependencies:
1225912263 - '@expo/dom-webview'
......@@ -12329,7 +12333,7 @@ snapshots:
1232912333 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1233012334 zod: 3.25.76
1233112335 optionalDependencies:
12332 expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f)
12336 expo-router: 56.2.11(db5c693a26481047569df6781f34db9f)
1233312337 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)
1233412338 transitivePeerDependencies:
1233512339 - '@expo/dom-webview'
......@@ -12669,7 +12673,7 @@ snapshots:
1266912673 react: 19.2.3
1267012674 optionalDependencies:
1267112675 '@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)
1267312677 react-dom: 19.2.3(react@19.2.3)
1267412678 transitivePeerDependencies:
1267512679 - supports-color
......@@ -12684,7 +12688,7 @@ snapshots:
1268412688 react: 19.2.6
1268512689 optionalDependencies:
1268612690 '@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)
1268812692 react-dom: 19.2.6(react@19.2.6)
1268912693 transitivePeerDependencies:
1269012694 - supports-color
......@@ -12987,7 +12991,7 @@ snapshots:
1298712991 dependencies:
1298812992 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)':
1299112995 dependencies:
1299212996 react: 19.2.3
1299312997 use-sync-external-store: 1.6.0(react@19.2.3)
......@@ -12995,7 +12999,7 @@ snapshots:
1299512999 react-dom: 19.2.3(react@19.2.3)
1299613000 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)':
1299913003 dependencies:
1300013004 react: 19.2.6
1300113005 use-sync-external-store: 1.6.0(react@19.2.6)
......@@ -14091,7 +14095,7 @@ snapshots:
1409114095 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)
1409214096 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)':
1409514099 dependencies:
1409614100 react: 19.2.3
1409714101 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:
1426914273 optionalDependencies:
1427014274 '@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)':
1427314277 dependencies:
1427414278 '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)
1427514279 '@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:
1427714281 react: 19.2.3
1427814282 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)
1427914283 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)
1428114285 sf-symbols-typescript: 2.2.0
1428214286 warn-once: 0.1.1
1428314287 transitivePeerDependencies:
......@@ -17056,7 +17060,7 @@ snapshots:
1705617060 - supports-color
1705717061 - typescript
1705817062
17059 expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7):
17063 expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8):
1706017064 dependencies:
1706117065 '@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)
1706217066 '@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:
1708717091 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)
1708817092 react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5)
1708917093 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)
1709117095 server-only: 0.0.1
1709217096 sf-symbols-typescript: 2.2.0
1709317097 shallowequal: 1.1.0
......@@ -17107,7 +17111,7 @@ snapshots:
1710717111 - supports-color
1710817112 optional: true
1710917113
17110 expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f):
17114 expo-router@56.2.11(db5c693a26481047569df6781f34db9f):
1711117115 dependencies:
1711217116 '@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)
1711317117 '@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:
1713817142 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)
1713917143 react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)
1714017144 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)
1714217146 server-only: 0.0.1
1714317147 sf-symbols-typescript: 2.2.0
1714417148 shallowequal: 1.1.0
......@@ -19953,14 +19957,14 @@ snapshots:
1995319957 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)
1995419958 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):
1995719961 dependencies:
1995819962 react: 19.2.3
1995919963 react-freeze: 1.0.4(react@19.2.3)
1996019964 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)
1996119965 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):
1996419968 dependencies:
1996519969 react: 19.2.6
1996619970 react-freeze: 1.0.4(react@19.2.6)
pnpm-workspace.yaml+4-1
......@@ -39,6 +39,7 @@ catalog:
3939 "@effect/sql-sqlite-bun": 4.0.0-beta.103
4040 "@effect/tsgo": 0.13.2
4141 "@effect/vitest": 4.0.0-beta.103
42 "@legendapp/list": 3.3.5
4243 "@noble/curves": 1.9.1
4344 "@noble/hashes": 1.8.0
4445 "@pierre/diffs": 1.3.0-beta.10
......@@ -74,6 +75,7 @@ minimumReleaseAgeExclude:
7475 - "@effect/vitest@4.0.0-beta.103"
7576 - alchemy@2.0.0-beta.65
7677 - effect@4.0.0-beta.103
78 - "@legendapp/list@3.3.5"
7779
7880overrides:
7981 "@clerk/backend": "catalog:"
......@@ -122,10 +124,11 @@ packageExtensions:
122124 vite: "catalog:"
123125
124126patchedDependencies:
127 "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch
125128 "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch
126129 "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch
127130 "@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.patch
131 "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch
129132 "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch
130133 "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
131134 "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch