diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index 409deab2ebc..bc107b0c3d2 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -25,6 +25,7 @@ import {
type ViewStyle,
} from "react-native";
import ImageViewing from "react-native-image-viewing";
+import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated";
import { useThemeColor } from "../../lib/useThemeColor";
import { scopedThreadKey } from "../../lib/scopedEntities";
@@ -112,6 +113,10 @@ export interface ThreadComposerProps {
* The pill / card container — renders as LiquidGlassView on supported
* iOS 26+ devices (progressive blur, native morph), opaque View otherwise.
*/
+// One timing for every piece of the expanded↔compact morph so the surface,
+// toolbar, and siblings move together instead of popping between layouts.
+const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220);
+
function ComposerSurface(props: {
readonly children: ReactNode;
readonly style: ViewStyle;
@@ -130,7 +135,7 @@ function ComposerSurface(props: {
if (isLiquidGlassSupported) {
return (
-
+
{props.children}
-
+
);
}
return (
-
+
{props.children}
-
+
);
}
@@ -669,7 +674,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
return (
-
-
{composerTrigger && composerMenuItems.length > 0 ? (
@@ -733,13 +740,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
>
{/* Attachment strip — inside the card, above the text input */}
{isExpanded ? (
- 0 ? 10 : 0 }}>
+ 0 ? 10 : 0 }}
+ >
-
+
) : null}
@@ -812,81 +823,87 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) : null}
{!isExpanded ? (
- showStopAction ? (
-
- ) : (
-
- )
+
+ {showStopAction ? (
+
+ ) : (
+
+ )}
+
) : null}
{/* Toolbar row — matches draft page layout (expanded only) */}
{isExpanded ? (
-
-
- void props.onPickDraftImages()}
- showChevron={false}
- />
- handleModelMenuAction(nativeEvent.event)}
- >
-
- }
- label={currentModelOption?.label ?? currentModelSelection.model}
- />
-
- handleOptionsMenuAction(nativeEvent.event)}
+
+
+
-
-
- {showStopAction ? (
void props.onPickDraftImages()}
showChevron={false}
/>
- ) : null}
-
-
-
+ handleModelMenuAction(nativeEvent.event)}
+ >
+
+ }
+ label={currentModelOption?.label ?? currentModelSelection.model}
+ />
+
+ handleOptionsMenuAction(nativeEvent.event)}
+ >
+
+
+ {showStopAction ? (
+
+ ) : null}
+
+
+
+
) : null}
{/* Queue count */}
{props.queueCount > 0 ? (
-
- {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
- automatically.
-
+
+
+ {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send
+ automatically.
+
+
) : null}
-
+
-
+
);
});
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index e0634102cb1..d110c010425 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -17,8 +17,9 @@ import type {
import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
-import { View, type GestureResponderEvent } from "react-native";
+import { Platform, View, type GestureResponderEvent } from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
+import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppText as Text } from "../../components/AppText";
@@ -187,7 +188,12 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s";
return (
-
+
@@ -200,7 +206,7 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
-
+
);
});
@@ -238,11 +244,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
const composerOverlapHeight = composerChrome + composerBottomInset;
const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0;
- const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8;
+ const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight;
+ // The overlay's measured height includes the home-indicator inset (the
+ // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
+ // UIKit add the safe-area bottom to the content inset AGAIN — leaving a
+ // dead strip between the resting content and the composer. Report the
+ // overlay height minus the safe area; UIKit adds it back, and ThreadFeed
+ // hands LegendList the same delta via contentInsetEndStaticAdjustment so
+ // its end-scroll math matches the real resting position.
+ const nativeInsetOvercount =
+ props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0;
const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(
listRef,
composerOverlayRef,
- estimatedOverlayHeight,
+ Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
+ -nativeInsetOvercount,
);
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const showContent = props.showContent ?? true;
@@ -408,18 +424,25 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
offset={{ closed: 0, opened: 0 }}
>
-
-
+ {/* No paddingTop here: the overlay's measured height becomes the
+ list's bottom inset, so any padding above the pill/composer
+ pushes the resting content floor up by the same amount. */}
+
+
{props.activeWorkStartedAt ? (
) : null}
{props.activePendingApproval || props.activePendingUserInput ? (
-
+
{props.activePendingApproval ? (
) : null}
-
+
) : null}
-
+
+
) : null}
-
+
);
}
@@ -804,8 +830,12 @@ function renderFeedEntry(
return null;
}
+ const enterAnimated = isFreshTimestamp(message.createdAt);
return (
-
+
{message.text.trim().length > 0 ? (
hasNativeSelectableMarkdownText() ? (
) : null}
-
+
);
}
@@ -1146,6 +1176,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const [viewportWidth, setViewportWidth] = useState(() =>
props.layoutVariant === "split" ? 0 : windowWidth,
);
+ const [viewportHeight, setViewportHeight] = useState(0);
const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
const [interactionState, setInteractionState] = useState<{
readonly copiedRowId: string | null;
@@ -1177,6 +1208,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const bottomContentInset = props.contentBottomInset ?? 18;
const usesNativeAutomaticInsets =
props.usesAutomaticContentInsets === true && Platform.OS === "ios";
+ // With automatic insets the header inset lives in UIKit's adjustedContentInset,
+ // which LegendList's JS anchoring math cannot see — it measures the anchored
+ // end space from the scroll view's frame top. Fold the header height back into
+ // the anchor offset or a just-sent message anchors underneath the header and
+ // the oversized end space keeps maintainScrollAtEnd snapping away from earlier
+ // messages. Read the context directly (useHeaderHeight throws outside a
+ // header-providing screen) and fall back to the standard iOS bar height.
+ const navigationHeaderHeight = useContext(HeaderHeightContext);
+ const anchorTopInset = usesNativeAutomaticInsets
+ ? navigationHeaderHeight || insets.top + 44
+ : topContentInset;
const iconSubtleColor = useThemeColor("--color-icon-subtle");
const userBubbleColor = useThemeColor("--color-user-bubble");
@@ -1248,7 +1290,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
);
const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
+ const nextHeight = Math.round(event.nativeEvent.layout.height);
setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current));
+ setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current));
}, []);
useEffect(() => {
@@ -1275,15 +1319,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
[expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn],
);
+ // The empty↔filled key below remounts the list, which resets its imperative
+ // content-inset override — and useKeyboardChatComposerInset (mounted above
+ // the remount boundary) deduplicates by height, so it never re-reports the
+ // composer inset to the fresh instance. Without this, the remounted list's
+ // initial scroll-to-end computes with a zero end inset and rests one
+ // composer-height short of the end. Layout effect: it must land before the
+ // list's first positioning tick or the one-shot initial scroll misses it.
+ const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`;
+ useLayoutEffect(() => {
+ const bottom = props.contentInsetEndAdjustment.value;
+ if (bottom > 0) {
+ props.listRef.current?.reportContentInset({ bottom });
+ }
+ }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]);
+
const anchoredEndSpace = useMemo(
() =>
resolveChatListAnchoredEndSpace(
presentedFeed,
props.anchorMessageId,
(entry) => (entry.type === "message" ? entry.id : null),
- { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET },
+ { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET },
),
- [presentedFeed, props.anchorMessageId, topContentInset],
+ [presentedFeed, props.anchorMessageId, anchorTopInset],
);
const terminalAssistantMessageIds = useMemo(() => {
const terminalIdsByTurn = new Map();
@@ -1506,7 +1565,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// an already-attached list under a transparent header can pin
// short content at offset 0 (one header-height too high). A fresh
// mount positions during attach, where UIKit applies the inset.
- key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}
+ key={listMountKey}
style={{ flex: 1 }}
// RN 0.81+ drops touches inside the contentInset area
// (facebook/react-native#54123); the anchored end space after a send
@@ -1527,13 +1586,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
: { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
+ itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION}
+ // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
+ // lets its scroll math clamp programmatic scrolls to -headerInset
+ // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
+ // content rest below the transparent header rather than at frame top.
+ contentInsetStartAdjustment={usesNativeAutomaticInsets ? anchorTopInset : 0}
contentInsetEndAdjustment={props.contentInsetEndAdjustment}
+ // UIKit's automatic behavior adds the safe-area bottom on top of the
+ // raw contentInset the keyboard integration writes. The detail screen
+ // under-reports the composer inset by this amount (see
+ // ThreadDetailScreen); this tells LegendList's scroll math about the
+ // extra so programmatic end scrolls land at the true resting offset.
+ contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}
+ // The keyboard integration's offset math (end pinning, max scroll)
+ // must add the same UIKit-added extra, or its keyboard-open end
+ // targets land one safe-area short of the true resting offset.
+ adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0}
freeze={props.freeze}
+ // Animated: on send, the optimistic message's dataChange fires
+ // maintainScrollAtEnd before any render-cycle suppression could
+ // engage — an instant snap there teleports the feed to the anchor
+ // instead of scrolling to it. Keeping it enabled (animated) during
+ // anchor scrolls also lets it correct a scroll that landed on a
+ // stale end target once the anchor row finishes measuring.
maintainScrollAtEnd={
disclosureToggleSettling
? false
: {
- animated: false,
+ animated: true,
on: {
dataChange: true,
itemLayout: true,
@@ -1552,6 +1633,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
keyboardShouldPersistTaps="always"
keyboardDismissMode="none"
keyboardLiftBehavior="whenAtEnd"
+ // Seed the list's scroll math with the real viewport before its own
+ // onLayout: the empty→filled remount can then tell at mount that
+ // short content underflows the viewport and skip programmatic
+ // positioning entirely (any offset write during screen attach races
+ // UIKit's adjustedContentInset application and lands high or low).
+ {...(viewportHeight > 0 && viewportWidth > 0
+ ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } }
+ : {})}
+ // RN's native scrollTo command clamps targets to a floor of
+ // -contentInset.top using the RAW inset — under automatic insets the
+ // header inset only exists in adjustedContentInset, so scrolls to
+ // negative offsets (content top below the transparent header) get
+ // clamped to 0. This prop disables that clamp; UIKit still bounces
+ // user overscroll back to the adjusted rest position.
+ scrollToOverflowEnabled
estimatedItemSize={180}
initialScrollAtEnd
onScroll={handleScroll}
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index 2bf3b5c9a15..24005a898ae 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -5,6 +5,7 @@ import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "re
import { AppText as Text } from "../../components/AppText";
import { cn } from "../../lib/cn";
import type { ThreadFeedActivity } from "../../lib/threadActivity";
+import Animated, { FadeIn } from "react-native-reanimated";
const WORK_LOG_LAYOUT_ANIMATION = {
duration: 180,
@@ -68,6 +69,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol {
}
}
+// Entering fades only for rows created moments ago: rows remount whenever the
+// list scrolls them back into view, and old rows must not replay an entrance.
+const FRESH_ROW_WINDOW_MS = 3_000;
+function isFreshRow(createdAt: string): boolean {
+ const timestamp = Date.parse(createdAt);
+ return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
+}
+
export function ThreadWorkLog(props: {
readonly activities: ReadonlyArray;
readonly copiedRowId: string | null;
@@ -104,7 +113,10 @@ export function ThreadWorkLog(props: {
const iconIsDestructive = row.icon === "alert" || row.icon === "warning";
return (
-
+
) : null}
-
+
);
})}
diff --git a/patches/@legendapp__list@3.2.0.patch b/patches/@legendapp__list@3.2.0.patch
new file mode 100644
index 00000000000..1231d4a867d
--- /dev/null
+++ b/patches/@legendapp__list@3.2.0.patch
@@ -0,0 +1,806 @@
+diff --git a/keyboard.d.ts b/keyboard.d.ts
+index 5a115ea..2c65d31 100644
+--- a/keyboard.d.ts
++++ b/keyboard.d.ts
+@@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = {
+ type KeyboardChatComposerRef = {
+ current: Pick | null;
+ };
+-declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): {
++declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): {
+ contentInsetEndAdjustment: SharedValue;
+ onComposerLayout: (event: LayoutChangeEvent) => void;
+ };
+@@ -278,8 +278,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb
+ scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise;
+ };
+ declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & {
++ adjustedInsetCompensation?: number;
+ anchoredEndSpace?: AnchoredEndSpaceConfig;
+ contentInsetEndAdjustment?: SharedValue;
++ contentInsetEndStaticAdjustment?: number;
+ keyboardOffset?: number;
+ } & React.RefAttributes) => React.ReactElement | null;
+
+diff --git a/keyboard.js b/keyboard.js
+index 736286a..8218172 100644
+--- a/keyboard.js
++++ b/keyboard.js
+@@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController.
+ "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
+ );
+ }
+-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {
+ const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight);
+ const lastHeightRef = React.useRef(void 0);
+ const reportHeight = React.useCallback(
+- (height) => {
++ (rawHeight) => {
++ const height = Math.max(0, rawHeight + heightAdjustment);
+ var _a;
+ if (Number.isFinite(height) && height !== lastHeightRef.current) {
+ lastHeightRef.current = height;
+ contentInsetEndAdjustment.value = height;
+- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
+ }
+ },
+- [contentInsetEndAdjustment, listRef]
++ [contentInsetEndAdjustment, heightAdjustment, listRef]
+ );
+ React.useLayoutEffect(() => {
+ var _a;
+@@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
+ }
+ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
+ const {
++ adjustedInsetCompensation,
+ anchoredEndSpace,
+ applyWorkaroundForContentInsetHitTestBug,
+ contentInsetEndAdjustment,
++ contentInsetEndStaticAdjustment,
+ freeze,
+ keyboardLiftBehavior,
+ keyboardOffset,
+@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
+ includeInEndInset: true,
+ onSizeChanged: (size) => {
+ var _a;
+- blankSpace.value = size;
++ // The anchored blank is sized against the ADJUSTED viewport, but the
++ // scroll component writes it as a raw contentInset that UIKit tops up
++ // with the safe-area bottom — write it net of that or the end rest
++ // (and the anchored message) sinks one safe-area under the header.
++ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
+ (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
+ }
+ };
+- }, [anchoredEndSpace, blankSpace]);
++ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
+ const onContentInsetChange = React.useCallback((insets) => {
+ var _a;
+ (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
+@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
+ reactNativeKeyboardController.KeyboardChatScrollView,
+ {
+ ...scrollProps,
++ adjustedInsetCompensation,
+ applyWorkaroundForContentInsetHitTestBug,
+ blankSpace,
+ extraContentPadding: contentInsetEndAdjustment,
+@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
+ );
+ },
+ [
++ adjustedInsetCompensation,
+ applyWorkaroundForContentInsetHitTestBug,
+ blankSpace,
+ contentInsetEndAdjustment,
+@@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
+ AnimatedLegendListInternal,
+ {
+ anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
++ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
+ ref: combinedRef,
+ renderScrollComponent: memoList,
+ ...rest
+diff --git a/keyboard.mjs b/keyboard.mjs
+index c1dd270..cb0d142 100644
+--- a/keyboard.mjs
++++ b/keyboard.mjs
+@@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) {
+ "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7."
+ );
+ }
+-function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) {
++function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) {
+ const contentInsetEndAdjustment = useSharedValue(initialHeight);
+ const lastHeightRef = useRef(void 0);
+ const reportHeight = useCallback(
+- (height) => {
++ (rawHeight) => {
++ const height = Math.max(0, rawHeight + heightAdjustment);
+ var _a;
+ if (Number.isFinite(height) && height !== lastHeightRef.current) {
+ lastHeightRef.current = height;
+ contentInsetEndAdjustment.value = height;
+- (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height });
+ }
+ },
+- [contentInsetEndAdjustment, listRef]
++ [contentInsetEndAdjustment, heightAdjustment, listRef]
+ );
+ useLayoutEffect(() => {
+ var _a;
+@@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) {
+ }
+ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) {
+ const {
++ adjustedInsetCompensation,
+ anchoredEndSpace,
+ applyWorkaroundForContentInsetHitTestBug,
+ contentInsetEndAdjustment,
++ contentInsetEndStaticAdjustment,
+ freeze,
+ keyboardLiftBehavior,
+ keyboardOffset,
+@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
+ includeInEndInset: true,
+ onSizeChanged: (size) => {
+ var _a;
+- blankSpace.value = size;
++ // The anchored blank is sized against the ADJUSTED viewport, but the
++ // scroll component writes it as a raw contentInset that UIKit tops up
++ // with the safe-area bottom — write it net of that or the end rest
++ // (and the anchored message) sinks one safe-area under the header.
++ blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0;
+ (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size);
+ }
+ };
+- }, [anchoredEndSpace, blankSpace]);
++ }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]);
+ const onContentInsetChange = useCallback((insets) => {
+ var _a;
+ (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets);
+@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
+ KeyboardChatScrollView,
+ {
+ ...scrollProps,
++ adjustedInsetCompensation,
+ applyWorkaroundForContentInsetHitTestBug,
+ blankSpace,
+ extraContentPadding: contentInsetEndAdjustment,
+@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
+ );
+ },
+ [
++ adjustedInsetCompensation,
+ applyWorkaroundForContentInsetHitTestBug,
+ blankSpace,
+ contentInsetEndAdjustment,
+@@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(
+ AnimatedLegendListInternal,
+ {
+ anchoredEndSpace: anchoredEndSpaceWithBlankSpace,
++ contentInsetEndAdjustment: contentInsetEndStaticAdjustment,
+ ref: combinedRef,
+ renderScrollComponent: memoList,
+ ...rest
+diff --git a/react-native.d.ts b/react-native.d.ts
+index 72d3f59..435a5fc 100644
+--- a/react-native.d.ts
++++ b/react-native.d.ts
+@@ -284,6 +284,12 @@ interface LegendListSpecificProps {
+ * The adjustment is also rendered as real content padding so the browser scroll range includes it.
+ */
+ contentInsetEndAdjustment?: number;
++ /**
++ * Width/height of a leading content inset applied natively outside the list's knowledge
++ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
++ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
++ */
++ contentInsetStartAdjustment?: number;
+ /**
+ * Number of columns to render items in.
+ * @default 1
+diff --git a/react-native.js b/react-native.js
+index 8d4ff89..618e7e3 100644
+--- a/react-native.js
++++ b/react-native.js
+@@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
+ }
+
+ // src/core/clampScrollOffset.ts
++function getContentInsetStartAdjustment(ctx) {
++ const adjustment = ctx.state.props.contentInsetStartAdjustment;
++ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
++}
+ function clampScrollOffset(ctx, offset, scrollTarget) {
+ const state = ctx.state;
+ const contentSize = getContentSize(ctx);
++ const minOffset = -getContentInsetStartAdjustment(ctx);
+ let clampedOffset = offset;
+ if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
+- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
++ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
+ const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
+ const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
+ const maxOffset = baseMaxOffset + extraEndOffset;
+ clampedOffset = Math.min(offset, maxOffset);
+ }
+- clampedOffset = Math.max(0, clampedOffset);
++ clampedOffset = Math.max(minOffset, clampedOffset);
+ return clampedOffset;
+ }
+
+@@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) {
+ finishScrollTo(ctx);
+ }
+ }
+-function scrollToFallbackOffset(ctx, offset) {
++function scrollToFallbackOffset(ctx, offset, animated) {
+ var _a3;
+ (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
+- animated: false,
++ animated: !!animated,
+ x: ctx.state.props.horizontal ? offset : 0,
+ y: ctx.state.props.horizontal ? 0 : offset
+ });
+@@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) {
+ });
+ scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
+ } else if (shouldRetryUnalignedEndScroll) {
+- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
++ if (!isActivelyAnimatingToEnd) {
++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
++ }
+ scheduleFallbackCheck(100);
+ } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
+ finishScrollTo(ctx);
+@@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) {
+ }
+ state.pendingMaintainScrollAtEnd = false;
+ if (shouldMaintainScrollAtEnd) {
++ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
++ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
++ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
++ return false;
++ }
+ const contentSize = getContentSize(ctx);
++ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ if (contentSize < state.scrollLength) {
+- state.scroll = 0;
++ state.scroll = -maintainInsetStartAdjustment;
++ if (maintainInsetStartAdjustment > 0) {
++ return true;
++ }
+ }
+ if (!state.maintainingScrollAtEnd) {
+ const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
+@@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) {
+ y: 0
+ });
+ } else {
+- scroller == null ? void 0 : scroller.scrollToEnd({
+- animated: maintainScrollAtEnd.animated
+- });
++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
++ if (insetStartAdjustment > 0) {
++ scroller == null ? void 0 : scroller.scrollTo({
++ animated: maintainScrollAtEnd.animated,
++ x: 0,
++ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
++ });
++ } else {
++ scroller == null ? void 0 : scroller.scrollToEnd({
++ animated: maintainScrollAtEnd.animated
++ });
++ }
+ }
+ setTimeout(
+ () => {
+@@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
+ if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
+ return 0;
+ }
+- const maxScroll = Math.max(0, totalSize - state.scrollLength);
++ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
++ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
++ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
+ const clampDelta = maxScroll - state.scroll;
+ if (unresolvedAmount < 0) {
+ return Math.max(unresolvedAmount, Math.min(0, clampDelta));
+@@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
+ settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
+ return true;
+ }
+- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
++ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
+ const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
+ const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
+ if (isAtExpectedNativeClamp) {
+@@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) {
+ if (diff > 0) {
+ diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
+ } else {
+- const maxScroll = Math.max(0, totalSize - state.scrollLength);
++ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
+ state.scroll = maxScroll;
+ state.scrollPending = maxScroll;
+ diff = 0;
+@@ -2374,8 +2402,72 @@ function scrollToIndex(ctx, {
+ }
+
+ // src/core/initialScroll.ts
++var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
++var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
++function startInsetEndSettleWatchdog(ctx) {
++ const state = ctx.state;
++ if (state.insetEndSettleWatchdogActive) {
++ return;
++ }
++ state.insetEndSettleWatchdogActive = true;
++ state.didUserDrag = false;
++ let frames = 0;
++ let settledFrames = 0;
++ const tick = () => {
++ // The user owns the scroll from their first drag — a re-pin here would
++ // pin the list under their finger.
++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
++ state.insetEndSettleWatchdogActive = false;
++ return;
++ }
++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
++ const contentSize = getContentSize(ctx);
++ const scrollLength = state.scrollLength;
++ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
++ const distance = endOffset - state.scroll;
++ // Estimated row sizes converging to measured ones can strand the initial
++ // end landing when the library's own end-anchor bookkeeping gives up.
++ // While still near the end (never fighting a user who scrolled away),
++ // re-pin to the current true end until sizes stop changing.
++ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
++ settledFrames = 0;
++ const scroller = state.refScroller.current;
++ if (scroller) {
++ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
++ }
++ } else {
++ settledFrames++;
++ }
++ }
++ requestAnimationFrame(tick);
++ };
++ requestAnimationFrame(tick);
++}
+ function dispatchInitialScroll(ctx, params) {
+ const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
++ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
++ if (ctx.state.scrollLength <= 0) {
++ // The scroll length is unknown, so the resolved target is meaningless;
++ // the initial-scroll machinery re-advances after layout.
++ return;
++ }
++ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
++ // Content underflows the viewport and nothing has scrolled yet: the
++ // untouched native rest position (UIKit's adjustedContentInset) IS the
++ // end position. Dispatching a negative scroll here would race UIKit's
++ // inset application during screen attach, so just finish the session.
++ finishInitialScroll(ctx, {
++ resolvedOffset
++ });
++ return;
++ }
++ ctx.state.didDispatchInsetInitialScroll = true;
++ if (target.viewPosition === 1) {
++ startInsetEndSettleWatchdog(ctx);
++ }
++ }
+ const requestedIndex = target.index;
+ const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
+ const itemSize = getItemSizeAtIndex(ctx, index);
+@@ -2804,7 +2896,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
+ return;
+ }
+ if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
+- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
++ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
++ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
+ if (!shouldKeepEndTargetAlive) {
+ if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
+ clearPendingInitialScrollFooterLayout(ctx, {
+@@ -4646,7 +4740,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
+ }
+ contentBelowAnchor += footerSize + stylePaddingBottom;
+ isReady = !hasUnknownTailSize;
+- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
++ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
++ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
+ } else if (anchorIndex >= 0) {
+ isReady = false;
+ }
+@@ -4664,6 +4759,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
+ updateScroll(ctx, state.scroll, true);
+ }
+ (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
++ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
++ set$(ctx, "anchoredEndSpaceSize", nextSize);
++ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
++ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
++ updateScroll(ctx, state.scroll, true);
++ }
+ }
+ return nextSize;
+ }
+@@ -6462,6 +6563,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ dataVersion,
+ drawDistance = 250,
+ contentInsetEndAdjustment,
++ contentInsetStartAdjustment,
+ estimatedItemSize = 100,
+ estimatedListSize,
+ extraData,
+@@ -6492,6 +6594,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ onLayout: onLayoutProp,
+ onLoad,
+ onMomentumScrollEnd,
++ onScrollBeginDrag,
+ onRefresh,
+ onScroll: onScrollProp,
+ onStartReached,
+@@ -6710,6 +6813,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ contentContainerAlignItems: contentContainerStyle.alignItems,
+ contentInset,
+ contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
++ contentInsetStartAdjustment,
+ data: dataProp,
+ dataVersion,
+ drawDistance,
+@@ -6789,6 +6893,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ return void 0;
+ }
+ const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
++ if (getContentInsetStartAdjustment(ctx) > 0) {
++ // With a native leading inset (transparent header + automatic insets),
++ // any mount-time contentOffset races UIKit's adjustedContentInset
++ // application. The gated initial-scroll dispatch positions the list once
++ // real sizes are known instead.
++ return void 0;
++ }
+ return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
+ }, [usesBootstrapInitialScroll]);
+ React2.useLayoutEffect(() => {
+@@ -6995,6 +7106,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ onMomentumScrollEnd(event);
+ }
+ },
++ onScrollBeginDrag: (event) => {
++ ctx.state.didUserDrag = true;
++ if (onScrollBeginDrag) {
++ onScrollBeginDrag(event);
++ }
++ },
+ onScroll: (event) => onScroll(ctx, event)
+ }),
+ []
+@@ -7019,6 +7136,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ onLayout,
+ onLayoutFooter,
+ onMomentumScrollEnd: fns.onMomentumScrollEnd,
++ onScrollBeginDrag: fns.onScrollBeginDrag,
+ onScroll: onScrollHandler,
+ recycleItems,
+ refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, {
+diff --git a/react-native.mjs b/react-native.mjs
+index 2e96ca7..84e167b 100644
+--- a/react-native.mjs
++++ b/react-native.mjs
+@@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) {
+ }
+
+ // src/core/clampScrollOffset.ts
++function getContentInsetStartAdjustment(ctx) {
++ const adjustment = ctx.state.props.contentInsetStartAdjustment;
++ return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0;
++}
+ function clampScrollOffset(ctx, offset, scrollTarget) {
+ const state = ctx.state;
+ const contentSize = getContentSize(ctx);
++ const minOffset = -getContentInsetStartAdjustment(ctx);
+ let clampedOffset = offset;
+ if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) {
+- const baseMaxOffset = Math.max(0, contentSize - state.scrollLength);
++ const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength);
+ const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset;
+ const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0;
+ const maxOffset = baseMaxOffset + extraEndOffset;
+ clampedOffset = Math.min(offset, maxOffset);
+ }
+- clampedOffset = Math.max(0, clampedOffset);
++ clampedOffset = Math.max(minOffset, clampedOffset);
+ return clampedOffset;
+ }
+
+@@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) {
+ finishScrollTo(ctx);
+ }
+ }
+-function scrollToFallbackOffset(ctx, offset) {
++function scrollToFallbackOffset(ctx, offset, animated) {
+ var _a3;
+ (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({
+- animated: false,
++ animated: !!animated,
+ x: ctx.state.props.horizontal ? offset : 0,
+ y: ctx.state.props.horizontal ? 0 : offset
+ });
+@@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) {
+ });
+ scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
+ } else if (shouldRetryUnalignedEndScroll) {
+- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset);
++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100;
++ if (!isActivelyAnimatingToEnd) {
++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
++ }
+ scheduleFallbackCheck(100);
+ } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) {
+ finishScrollTo(ctx);
+@@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) {
+ }
+ state.pendingMaintainScrollAtEnd = false;
+ if (shouldMaintainScrollAtEnd) {
++ const maintainAnchoredEndSpace = state.props.anchoredEndSpace;
++ const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex;
++ if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) {
++ return false;
++ }
+ const contentSize = getContentSize(ctx);
++ const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx);
+ if (contentSize < state.scrollLength) {
+- state.scroll = 0;
++ state.scroll = -maintainInsetStartAdjustment;
++ if (maintainInsetStartAdjustment > 0) {
++ return true;
++ }
+ }
+ if (!state.maintainingScrollAtEnd) {
+ const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant";
+@@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) {
+ y: 0
+ });
+ } else {
+- scroller == null ? void 0 : scroller.scrollToEnd({
+- animated: maintainScrollAtEnd.animated
+- });
++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
++ if (insetStartAdjustment > 0) {
++ scroller == null ? void 0 : scroller.scrollTo({
++ animated: maintainScrollAtEnd.animated,
++ x: 0,
++ y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength)
++ });
++ } else {
++ scroller == null ? void 0 : scroller.scrollToEnd({
++ animated: maintainScrollAtEnd.animated
++ });
++ }
+ }
+ setTimeout(
+ () => {
+@@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) {
+ if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) {
+ return 0;
+ }
+- const maxScroll = Math.max(0, totalSize - state.scrollLength);
++ const insetStartAdjustment = state.props.contentInsetStartAdjustment;
++ const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0;
++ const maxScroll = Math.max(minScroll, totalSize - state.scrollLength);
+ const clampDelta = maxScroll - state.scroll;
+ if (unresolvedAmount < 0) {
+ return Math.max(unresolvedAmount, Math.min(0, clampDelta));
+@@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) {
+ settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta);
+ return true;
+ }
+- const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength);
++ const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength);
+ const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll);
+ const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON;
+ if (isAtExpectedNativeClamp) {
+@@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) {
+ if (diff > 0) {
+ diff = Math.max(0, totalSize - state.scroll - state.scrollLength);
+ } else {
+- const maxScroll = Math.max(0, totalSize - state.scrollLength);
++ const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength);
+ state.scroll = maxScroll;
+ state.scrollPending = maxScroll;
+ diff = 0;
+@@ -2353,8 +2381,72 @@ function scrollToIndex(ctx, {
+ }
+
+ // src/core/initialScroll.ts
++var INSET_END_SETTLE_WATCHDOG_FRAMES = 150;
++var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20;
++function startInsetEndSettleWatchdog(ctx) {
++ const state = ctx.state;
++ if (state.insetEndSettleWatchdogActive) {
++ return;
++ }
++ state.insetEndSettleWatchdogActive = true;
++ state.didUserDrag = false;
++ let frames = 0;
++ let settledFrames = 0;
++ const tick = () => {
++ // The user owns the scroll from their first drag — a re-pin here would
++ // pin the list under their finger.
++ if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) {
++ state.insetEndSettleWatchdogActive = false;
++ return;
++ }
++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
++ const contentSize = getContentSize(ctx);
++ const scrollLength = state.scrollLength;
++ if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) {
++ const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength);
++ const distance = endOffset - state.scroll;
++ // Estimated row sizes converging to measured ones can strand the initial
++ // end landing when the library's own end-anchor bookkeeping gives up.
++ // While still near the end (never fighting a user who scrolled away),
++ // re-pin to the current true end until sizes stop changing.
++ if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) {
++ settledFrames = 0;
++ const scroller = state.refScroller.current;
++ if (scroller) {
++ scroller.scrollTo({ animated: false, x: 0, y: endOffset });
++ }
++ } else {
++ settledFrames++;
++ }
++ }
++ requestAnimationFrame(tick);
++ };
++ requestAnimationFrame(tick);
++}
+ function dispatchInitialScroll(ctx, params) {
+ const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params;
++ const insetStartAdjustment = getContentInsetStartAdjustment(ctx);
++ if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) {
++ if (ctx.state.scrollLength <= 0) {
++ // The scroll length is unknown, so the resolved target is meaningless;
++ // the initial-scroll machinery re-advances after layout.
++ return;
++ }
++ if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) {
++ // Content underflows the viewport and nothing has scrolled yet: the
++ // untouched native rest position (UIKit's adjustedContentInset) IS the
++ // end position. Dispatching a negative scroll here would race UIKit's
++ // inset application during screen attach, so just finish the session.
++ finishInitialScroll(ctx, {
++ resolvedOffset
++ });
++ return;
++ }
++ ctx.state.didDispatchInsetInitialScroll = true;
++ if (target.viewPosition === 1) {
++ startInsetEndSettleWatchdog(ctx);
++ }
++ }
+ const requestedIndex = target.index;
+ const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0;
+ const itemSize = getItemSizeAtIndex(ctx, index);
+@@ -2783,7 +2875,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) {
+ return;
+ }
+ if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) {
+- const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd");
++ const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx);
++ const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5;
++ const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList);
+ if (!shouldKeepEndTargetAlive) {
+ if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) {
+ clearPendingInitialScrollFooterLayout(ctx, {
+@@ -4625,7 +4719,8 @@ function maybeUpdateAnchoredEndSpace(ctx) {
+ }
+ contentBelowAnchor += footerSize + stylePaddingBottom;
+ isReady = !hasUnknownTailSize;
+- nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
++ const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset);
++ nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound;
+ } else if (anchorIndex >= 0) {
+ isReady = false;
+ }
+@@ -4643,6 +4738,12 @@ function maybeUpdateAnchoredEndSpace(ctx) {
+ updateScroll(ctx, state.scroll, true);
+ }
+ (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize });
++ } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) {
++ set$(ctx, "anchoredEndSpaceSize", nextSize);
++ (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize);
++ if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) {
++ updateScroll(ctx, state.scroll, true);
++ }
+ }
+ return nextSize;
+ }
+@@ -6441,6 +6542,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ dataVersion,
+ drawDistance = 250,
+ contentInsetEndAdjustment,
++ contentInsetStartAdjustment,
+ estimatedItemSize = 100,
+ estimatedListSize,
+ extraData,
+@@ -6471,6 +6573,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ onLayout: onLayoutProp,
+ onLoad,
+ onMomentumScrollEnd,
++ onScrollBeginDrag,
+ onRefresh,
+ onScroll: onScrollProp,
+ onStartReached,
+@@ -6689,6 +6792,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ contentContainerAlignItems: contentContainerStyle.alignItems,
+ contentInset,
+ contentInsetEndAdjustment: contentInsetEndAdjustmentResolved,
++ contentInsetStartAdjustment,
+ data: dataProp,
+ dataVersion,
+ drawDistance,
+@@ -6768,6 +6872,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ return void 0;
+ }
+ const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll);
++ if (getContentInsetStartAdjustment(ctx) > 0) {
++ // With a native leading inset (transparent header + automatic insets),
++ // any mount-time contentOffset races UIKit's adjustedContentInset
++ // application. The gated initial-scroll dispatch positions the list once
++ // real sizes are known instead.
++ return void 0;
++ }
+ return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset;
+ }, [usesBootstrapInitialScroll]);
+ useLayoutEffect(() => {
+@@ -6974,6 +7085,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ onMomentumScrollEnd(event);
+ }
+ },
++ onScrollBeginDrag: (event) => {
++ ctx.state.didUserDrag = true;
++ if (onScrollBeginDrag) {
++ onScrollBeginDrag(event);
++ }
++ },
+ onScroll: (event) => onScroll(ctx, event)
+ }),
+ []
+@@ -6998,6 +7115,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
+ onLayout,
+ onLayoutFooter,
+ onMomentumScrollEnd: fns.onMomentumScrollEnd,
++ onScrollBeginDrag: fns.onScrollBeginDrag,
+ onScroll: onScrollHandler,
+ recycleItems,
+ refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, {
+diff --git a/reanimated.d.ts b/reanimated.d.ts
+index 7e2d11f..d5b0d66 100644
+--- a/reanimated.d.ts
++++ b/reanimated.d.ts
+@@ -285,6 +285,12 @@ interface LegendListSpecificProps {
+ * The adjustment is also rendered as real content padding so the browser scroll range includes it.
+ */
+ contentInsetEndAdjustment?: number;
++ /**
++ * Width/height of a leading content inset applied natively outside the list's knowledge
++ * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header).
++ * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header.
++ */
++ contentInsetStartAdjustment?: number;
+ /**
+ * Number of columns to render items in.
+ * @default 1
diff --git a/patches/react-native-keyboard-controller@1.21.13.patch b/patches/react-native-keyboard-controller@1.21.13.patch
new file mode 100644
index 00000000000..3dee935cda3
--- /dev/null
+++ b/patches/react-native-keyboard-controller@1.21.13.patch
@@ -0,0 +1,483 @@
+diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js
+index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644
+--- a/lib/commonjs/components/KeyboardChatScrollView/index.js
++++ b/lib/commonjs/components/KeyboardChatScrollView/index.js
+@@ -26,9 +26,11 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
+ offset = 0,
+ extraContentPadding = ZERO_CONTENT_PADDING,
+ blankSpace = ZERO_BLANK_SPACE,
++ adjustedInsetCompensation = 0,
+ applyWorkaroundForContentInsetHitTestBug = false,
+ onLayout: onLayoutProp,
+ onContentSizeChange: onContentSizeChangeProp,
++ onContentInsetChange,
+ onEndVisible,
+ ...rest
+ }, ref) => {
+@@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
+ freeze: freezeSV,
+ offset,
+ blankSpace,
+- extraContentPadding
++ extraContentPadding,
++ adjustedInsetCompensation
+ });
+ (0, _useExtraContentPadding.useExtraContentPadding)({
+ scrollViewRef,
+ extraContentPadding,
+ keyboardPadding: padding,
+ blankSpace,
++ adjustedInsetCompensation,
+ scroll,
+ layout,
+ size,
+@@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({
+ // a bug for you, please open an issue.
+ const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
+
++ // Mirror the effective bottom padding (keyboard + composer + blank floor)
++ // to the consumer - a virtualized list needs it in its own scroll math or
++ // its end/maintain targets point at the under-the-keyboard resting offset.
++ (0, _reactNativeReanimated.useAnimatedReaction)(() => totalPadding.value, (current, previous) => {
++ if (onContentInsetChange && current !== previous) {
++ (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({
++ bottom: current
++ });
++ }
++ }, [onContentInsetChange]);
++
+ // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
+ // Apps that render into the unsafe area can supply a negative
+ // scrollIndicatorInsets adjustment at the application layer.
+- const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value);
++ const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value);
+ const onLayout = (0, _react.useCallback)(e => {
+ onLayoutInternal(e);
+ onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
+diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
+index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644
+--- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
++++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
+@@ -32,7 +32,8 @@ function useChatKeyboard(scrollViewRef, options) {
+ freeze,
+ offset,
+ blankSpace,
+- extraContentPadding
++ extraContentPadding,
++ adjustedInsetCompensation
+ } = options;
+ const padding = (0, _reactNativeReanimated.useSharedValue)(0);
+ const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0);
+@@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) {
+ const visiblePadding = visibleFraction * blankSpace.value;
+ const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
+ const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed);
+- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value);
++ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation;
+
+ // persistent mode: when keyboard shrinks, clamp to valid range
+ if (keyboardLiftBehavior === "persistent" && effective < padding.value) {
+@@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) {
+ const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset);
+ padding.value = effective;
+ }
+- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]);
+ return {
+ padding,
+ currentHeight,
+diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
+index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644
+--- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
++++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js
+@@ -29,6 +29,7 @@ function useExtraContentPadding(options) {
+ extraContentPadding,
+ keyboardPadding,
+ blankSpace,
++ adjustedInsetCompensation,
+ scroll,
+ layout,
+ size,
+@@ -68,8 +69,8 @@ function useExtraContentPadding(options) {
+ }
+
+ // Compute effective delta considering blankSpace floor
+- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous);
+- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current);
++ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation;
++ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation;
+ const effectiveDelta = currentTotal - previousTotal;
+ if (effectiveDelta === 0) {
+ // blankSpace absorbed the change
+@@ -92,6 +93,6 @@ function useExtraContentPadding(options) {
+ const target = Math.min(scroll.value + effectiveDelta, maxScroll);
+ scrollToTarget(target);
+ }
+- }, [inverted, keyboardLiftBehavior]);
++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]);
+ }
+ //# sourceMappingURL=index.js.map
+\ No newline at end of file
+diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js
+index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644
+--- a/lib/module/components/KeyboardChatScrollView/index.js
++++ b/lib/module/components/KeyboardChatScrollView/index.js
+@@ -1,7 +1,7 @@
+ function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
+ import React, { forwardRef, useCallback, useMemo } from "react";
+ import { StyleSheet } from "react-native";
+-import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
++import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
+ import Reanimated from "react-native-reanimated";
+ import useCombinedRef from "../hooks/useCombinedRef";
+ import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding";
+@@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
+ offset = 0,
+ extraContentPadding = ZERO_CONTENT_PADDING,
+ blankSpace = ZERO_BLANK_SPACE,
++ adjustedInsetCompensation = 0,
+ applyWorkaroundForContentInsetHitTestBug = false,
+ onLayout: onLayoutProp,
+ onContentSizeChange: onContentSizeChangeProp,
++ onContentInsetChange,
+ onEndVisible,
+ ...rest
+ }, ref) => {
+@@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
+ freeze: freezeSV,
+ offset,
+ blankSpace,
+- extraContentPadding
++ extraContentPadding,
++ adjustedInsetCompensation
+ });
+ useExtraContentPadding({
+ scrollViewRef,
+ extraContentPadding,
+ keyboardPadding: padding,
+ blankSpace,
++ adjustedInsetCompensation,
+ scroll,
+ layout,
+ size,
+@@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({
+ // a bug for you, please open an issue.
+ const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value)));
+
++ // Mirror the effective bottom padding (keyboard + composer + blank floor)
++ // to the consumer - a virtualized list needs it in its own scroll math or
++ // its end/maintain targets point at the under-the-keyboard resting offset.
++ useAnimatedReaction(() => totalPadding.value, (current, previous) => {
++ if (onContentInsetChange && current !== previous) {
++ runOnJS(onContentInsetChange)({
++ bottom: current
++ });
++ }
++ }, [onContentInsetChange]);
++
+ // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
+ // Apps that render into the unsafe area can supply a negative
+ // scrollIndicatorInsets adjustment at the application layer.
+- const indicatorPadding = useDerivedValue(() => padding.value + extraContentPadding.value);
++ const indicatorPadding = useDerivedValue(() => padding.value);
+ const onLayout = useCallback(e => {
+ onLayoutInternal(e);
+ onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e);
+diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
+index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644
+--- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
++++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js
+@@ -25,7 +25,8 @@ function useChatKeyboard(scrollViewRef, options) {
+ freeze,
+ offset,
+ blankSpace,
+- extraContentPadding
++ extraContentPadding,
++ adjustedInsetCompensation
+ } = options;
+ const padding = useSharedValue(0);
+ const currentHeight = useSharedValue(0);
+@@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) {
+ const visiblePadding = visibleFraction * blankSpace.value;
+ const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value);
+ const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed);
+- const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value);
++ const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation;
+
+ // persistent mode: when keyboard shrinks, clamp to valid range
+ if (keyboardLiftBehavior === "persistent" && effective < padding.value) {
+@@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) {
+ const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset);
+ padding.value = effective;
+ }
+- }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]);
++ }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]);
+ return {
+ padding,
+ currentHeight,
+diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
+index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644
+--- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
++++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js
+@@ -23,6 +23,7 @@ function useExtraContentPadding(options) {
+ extraContentPadding,
+ keyboardPadding,
+ blankSpace,
++ adjustedInsetCompensation,
+ scroll,
+ layout,
+ size,
+@@ -62,8 +63,8 @@ function useExtraContentPadding(options) {
+ }
+
+ // Compute effective delta considering blankSpace floor
+- const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous);
+- const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current);
++ const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation;
++ const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation;
+ const effectiveDelta = currentTotal - previousTotal;
+ if (effectiveDelta === 0) {
+ // blankSpace absorbed the change
+@@ -86,7 +87,7 @@ function useExtraContentPadding(options) {
+ const target = Math.min(scroll.value + effectiveDelta, maxScroll);
+ scrollToTarget(target);
+ }
+- }, [inverted, keyboardLiftBehavior]);
++ }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]);
+ }
+ export { useExtraContentPadding };
+ //# sourceMappingURL=index.js.map
+\ No newline at end of file
+diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
+index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644
+--- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts
++++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts
+@@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = {
+ * Default is `undefined` (equivalent to `0` — no minimum floor).
+ */
+ blankSpace?: SharedValue;
++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
++ adjustedInsetCompensation?: number;
+ /**
+ * Fires whenever the effective content inset changes — the static `contentInset`
+ * prop combined with the dynamic keyboard-driven padding.
+diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
+index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644
+--- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
++++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts
+@@ -9,6 +9,8 @@ type UseChatKeyboardOptions = {
+ blankSpace: SharedValue;
+ /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
+ extraContentPadding: SharedValue;
++ /** Safe-area extra beyond raw contentInset. Offset math only. */
++ adjustedInsetCompensation: number;
+ };
+ type UseChatKeyboardReturn = {
+ /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */
+diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
+index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644
+--- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
++++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts
+@@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = {
+ keyboardPadding: SharedValue;
+ /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
+ blankSpace: SharedValue;
++ /** Safe-area extra beyond raw contentInset. Offset math only. */
++ adjustedInsetCompensation: number;
+ /** Current vertical scroll offset. */
+ scroll: SharedValue;
+ /** Visible viewport dimensions. */
+diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx
+index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644
+--- a/src/components/KeyboardChatScrollView/index.tsx
++++ b/src/components/KeyboardChatScrollView/index.tsx
+@@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react";
+ import { StyleSheet } from "react-native";
+ import {
+ makeMutable,
++ runOnJS,
++ useAnimatedReaction,
+ useAnimatedRef,
+ useAnimatedStyle,
+ useDerivedValue,
+@@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef<
+ offset = 0,
+ extraContentPadding = ZERO_CONTENT_PADDING,
+ blankSpace = ZERO_BLANK_SPACE,
++ adjustedInsetCompensation = 0,
+ applyWorkaroundForContentInsetHitTestBug = false,
+ onLayout: onLayoutProp,
+ onContentSizeChange: onContentSizeChangeProp,
++ onContentInsetChange,
+ onEndVisible,
+ ...rest
+ },
+@@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef<
+ offset,
+ blankSpace,
+ extraContentPadding,
++ adjustedInsetCompensation,
+ });
+
+ useExtraContentPadding({
+@@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef<
+ extraContentPadding,
+ keyboardPadding: padding,
+ blankSpace,
++ adjustedInsetCompensation,
+ scroll,
+ layout,
+ size,
+@@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef<
+ ),
+ );
+
+- // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace).
+- // Apps that render into the unsafe area can supply a negative
+- // scrollIndicatorInsets adjustment at the application layer.
+- const indicatorPadding = useDerivedValue(
+- () => padding.value + extraContentPadding.value,
++ // Mirror the effective bottom padding (keyboard + composer + blank floor)
++ // to the consumer — a virtualized list needs it in its own scroll math or
++ // its end/maintain targets point at the under-the-keyboard resting offset.
++ useAnimatedReaction(
++ () => totalPadding.value,
++ (current, previous) => {
++ if (onContentInsetChange && current !== previous) {
++ runOnJS(onContentInsetChange)({ bottom: current });
++ }
++ },
++ [onContentInsetChange],
+ );
+
++ // Scroll indicator inset = keyboard only (excludes extraContentPadding and
++ // blankSpace): with a floating composer the indicator track should run the
++ // full height of the scroll view, behind the composer, like iOS Messages.
++ // The keyboard still lifts it so it never disappears under the keyboard.
++ const indicatorPadding = useDerivedValue(() => padding.value);
++
+ const onLayout = useCallback(
+ (e: LayoutChangeEvent) => {
+ onLayoutInternal(e);
+diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts
+index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644
+--- a/src/components/KeyboardChatScrollView/types.ts
++++ b/src/components/KeyboardChatScrollView/types.ts
+@@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = {
+ * Default is `undefined` (equivalent to `0` — no minimum floor).
+ */
+ blankSpace?: SharedValue;
++ /**
++ * Extra bottom inset UIKit adds on top of the raw `contentInset` this
++ * component writes (e.g. the home-indicator safe area under
++ * `contentInsetAdjustmentBehavior="automatic"`). Used ONLY in scroll-offset
++ * math (max scroll / end pinning) — never written into `contentInset`.
++ *
++ * Default is `0`.
++ */
++ adjustedInsetCompensation?: number;
+ /**
+ * Fires whenever the effective content inset changes — the static `contentInset`
+ * prop combined with the dynamic keyboard-driven padding.
+diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
+index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644
+--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts
+@@ -43,6 +43,7 @@ function useChatKeyboard(
+ offset,
+ blankSpace,
+ extraContentPadding,
++ adjustedInsetCompensation,
+ } = options;
+
+ const padding = useSharedValue(0);
+@@ -104,10 +105,12 @@ function useChatKeyboard(
+ effective,
+ minimumPaddingAbsorbed,
+ );
+- const actualTotalPadding = Math.max(
+- blankSpace.value,
+- effective + extraContentPadding.value,
+- );
++ // UIKit adds adjustedInsetCompensation (safe area) on top of the raw
++ // inset; include it here so end/max-scroll targets match the real
++ // resting offsets.
++ const actualTotalPadding =
++ Math.max(blankSpace.value, effective + extraContentPadding.value) +
++ adjustedInsetCompensation;
+
+ // persistent mode: when keyboard shrinks, clamp to valid range
+ if (
+@@ -242,7 +245,7 @@ function useChatKeyboard(
+ padding.value = effective;
+ },
+ },
+- [inverted, keyboardLiftBehavior, offset, extraContentPadding],
++ [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation],
+ );
+
+ return {
+diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
+index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644
+--- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
++++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts
+@@ -11,6 +11,8 @@ type UseChatKeyboardOptions = {
+ blankSpace: SharedValue;
+ /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */
+ extraContentPadding: SharedValue;
++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
++ adjustedInsetCompensation: number;
+ };
+
+ type UseChatKeyboardReturn = {
+diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644
+--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
++++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+@@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = {
+ keyboardPadding: SharedValue;
+ /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */
+ blankSpace: SharedValue;
++ /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */
++ adjustedInsetCompensation: number;
+ /** Current vertical scroll offset. */
+ scroll: SharedValue;
+ /** Visible viewport dimensions. */
+@@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
+ extraContentPadding,
+ keyboardPadding,
+ blankSpace,
++ adjustedInsetCompensation,
+ scroll,
+ layout,
+ size,
+@@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
+ }
+
+ // Compute effective delta considering blankSpace floor
+- const previousTotal = Math.max(
+- blankSpace.value,
+- keyboardPadding.value + previous,
+- );
+- const currentTotal = Math.max(
+- blankSpace.value,
+- keyboardPadding.value + current,
+- );
++ const previousTotal =
++ Math.max(blankSpace.value, keyboardPadding.value + previous) +
++ adjustedInsetCompensation;
++ const currentTotal =
++ Math.max(blankSpace.value, keyboardPadding.value + current) +
++ adjustedInsetCompensation;
+ const effectiveDelta = currentTotal - previousTotal;
+
+ if (effectiveDelta === 0) {
+@@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
+ scrollToTarget(target);
+ }
+ },
+- [inverted, keyboardLiftBehavior],
++ [inverted, keyboardLiftBehavior, adjustedInsetCompensation],
+ );
+ }
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ef7146e194a..855074099c2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -76,6 +76,9 @@ patchedDependencies:
'@ff-labs/fff-node@0.9.4':
hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8
path: patches/@ff-labs__fff-node@0.9.4.patch
+ '@legendapp/list@3.2.0':
+ hash: c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347
+ path: patches/@legendapp__list@3.2.0.patch
'@pierre/diffs@1.3.0-beta.5':
hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a
path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch
@@ -94,6 +97,9 @@ patchedDependencies:
react-native-gesture-handler@2.31.2:
hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
path: patches/react-native-gesture-handler@2.31.2.patch
+ react-native-keyboard-controller@1.21.13:
+ hash: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008
+ path: patches/react-native-keyboard-controller@1.21.13.patch
react-native-nitro-modules@0.35.9:
hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
path: patches/react-native-nitro-modules@0.35.9.patch
@@ -226,7 +232,7 @@ importers:
version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3)
'@legendapp/list':
specifier: 3.2.0
- version: 3.2.0(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
+ version: 3.2.0(patch_hash=c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
'@noble/curves':
specifier: 'catalog:'
version: 1.9.1
@@ -379,7 +385,7 @@ importers:
version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
react-native-keyboard-controller:
specifier: 1.21.13
- version: 1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
+ version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
react-native-nitro-markdown:
specifier: ^0.5.0
version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
@@ -537,7 +543,7 @@ importers:
version: 0.9.0
'@legendapp/list':
specifier: 3.2.0
- version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ version: 3.2.0(patch_hash=c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@lexical/react':
specifier: ^0.41.0
version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
@@ -12654,7 +12660,7 @@ snapshots:
dependencies:
jsbi: 4.3.2
- '@legendapp/list@3.2.0(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
+ '@legendapp/list@3.2.0(patch_hash=c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347)(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))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
dependencies:
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
@@ -12662,7 +12668,7 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
- '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ '@legendapp/list@3.2.0(patch_hash=c7ba4f6f27e5bed1c226d4760647bfdd6dc9619956f0deabdba26237e3fc2347)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
react: 19.2.6
use-sync-external-store: 1.6.0(react@19.2.6)
@@ -18950,7 +18956,7 @@ snapshots:
react: 19.2.3
react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
- react-native-keyboard-controller@1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
+ react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
dependencies:
react: 19.2.3
react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 25e6fd2889e..cc61d023d78 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -5,20 +5,6 @@ packages:
- packages/*
- scripts
-# Install both Windows and Linux native binaries so the WSL (Linux) backend
-# can load platform-gated optional deps (e.g. @yuuang/ffi-rs-linux-x64-gnu)
-# out of the same node_modules the Windows desktop uses.
-supportedArchitectures:
- os:
- - current
- - linux
- cpu:
- - current
- - x64
- libc:
- - current
- - glibc
-
catalog:
"@clerk/backend": 3.8.4
"@clerk/clerk-js": 6.22.0
@@ -84,8 +70,6 @@ overrides:
"@pierre/diffs>@shikijs/transformers": ^4.2.0
"@types/node": "catalog:"
effect: "catalog:"
- # Pin to the version our patch targets; expo-modules-core's ~56.0.10 range
- # otherwise floats to newer releases on fresh resolves (ERR_PNPM_UNUSED_PATCH).
expo-modules-jsi: 56.0.10
vite: "catalog:"
yaml: "catalog:"
@@ -105,12 +89,14 @@ patchedDependencies:
"@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch
"@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch
"@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch
+ "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch
"@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch
"@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
"@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch
effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch
expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch
react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch
+ react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch
react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch
react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch
@@ -119,3 +105,8 @@ peerDependencyRules:
- vite
allowedVersions:
vite: "*"
+
+supportedArchitectures:
+ cpu: [current, x64]
+ libc: [current, glibc]
+ os: [current, linux]