From ec7a60b9f5b2c206785ec5ee1bf2fe98ab850fb9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 12:11:29 +0200 Subject: [PATCH 01/46] fix(mobile): keep composer anchored after keyboard dismissal - Disable stale keyboard translation when the IME is hidden - Pause live-follow immediately on user scroll and re-arm only at the actual end - Add focused live-follow transition tests Co-authored-by: codex --- .../features/threads/ThreadDetailScreen.tsx | 11 ++++- .../src/features/threads/ThreadFeed.tsx | 45 ++++++++++++------- .../threads/thread-feed-live-follow.test.ts | 40 +++++++++++++++++ .../threads/thread-feed-live-follow.ts | 25 +++++++++++ 4 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-feed-live-follow.test.ts create mode 100644 apps/mobile/src/features/threads/thread-feed-live-follow.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3d83c837500..3da5df79e39 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -17,7 +17,11 @@ import type { import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, View, type GestureResponderEvent } from "react-native"; -import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; +import { + KeyboardController, + KeyboardStickyView, + useKeyboardState, +} from "react-native-keyboard-controller"; import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -173,6 +177,7 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray state.isVisible); const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); @@ -383,6 +388,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7933e4ca601..756a74c3d5b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -89,6 +89,10 @@ import { type ThreadFeedLatestTurn, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { + resolveThreadFeedLiveFollow, + type ThreadFeedLiveFollowEvent, +} from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, ThreadWorkGroupToggle, @@ -1349,6 +1353,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { endFollowEnabledRef.current = enabled; setEndFollowEnabled(enabled); }, []); + const transitionEndFollow = useCallback( + (event: ThreadFeedLiveFollowEvent) => { + setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); + }, + [setEndFollow], + ); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1460,26 +1470,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the // header height back or the material toggles a full header too late. reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); - // Latch bookkeeping. LegendList recomputes its inset-aware end distance - // before invoking this handler, so getState() is current. Returning to - // the end re-arms follow no matter who scrolled (the user, or our own - // scroll-to-end); moving away breaks it only during a user-initiated - // scroll session, so MVCP compensations and programmatic repositioning - // can never strand a follower. + // LegendList recomputes its inset-aware end distance before invoking + // this handler, so getState() is current. Only the actual end re-arms + // follow: its broader maintain-scroll threshold is large enough for a + // streaming chunk to pull a user back before their upward drag escapes. const listState = props.listRef.current?.getState(); if (listState) { - if (listState.isWithinMaintainScrollAtEndThreshold) { - setEndFollow(true); - } else if (userScrollSessionRef.current) { - setEndFollow(false); - } + transitionEndFollow({ + type: "scroll", + isAtEnd: listState.isAtEnd, + userScrollSessionActive: userScrollSessionRef.current, + }); } }, - [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, setEndFollow], + [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], ); const handleScrollBeginDrag = useCallback(() => { userScrollSessionRef.current = true; - }, []); + // Pause before the first scroll event. Otherwise a stream update can run + // maintainScrollAtEnd between touch-down and the drag leaving its threshold. + transitionEndFollow({ type: "user-scroll-begin" }); + }, [transitionEndFollow]); // The session must survive past finger-lift so momentum that carries the // user away from the end still breaks follow; a drag released with no // momentum ends its session at the release itself, otherwise at momentum @@ -1511,14 +1522,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // re-arm follow regardless of where the user had scrolled before. useEffect(() => { userScrollSessionRef.current = false; - setEndFollow(true); - }, [props.threadId, setEndFollow]); + transitionEndFollow({ type: "reset" }); + }, [props.threadId, transitionEndFollow]); useEffect(() => { if (props.anchorMessageId !== null) { userScrollSessionRef.current = false; - setEndFollow(true); + transitionEndFollow({ type: "reset" }); } - }, [props.anchorMessageId, setEndFollow]); + }, [props.anchorMessageId, transitionEndFollow]); const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts new file mode 100644 index 00000000000..46894da17f9 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; + +describe("resolveThreadFeedLiveFollow", () => { + it("pauses immediately when the user starts scrolling", () => { + expect(resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" })).toBe(false); + }); + + it("stays paused away from the actual end", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "scroll", + isAtEnd: false, + userScrollSessionActive: true, + }), + ).toBe(false); + }); + + it("does not mistake programmatic layout compensation for a user scroll", () => { + expect( + resolveThreadFeedLiveFollow(true, { + type: "scroll", + isAtEnd: false, + userScrollSessionActive: false, + }), + ).toBe(true); + }); + + it("re-arms only at the actual end or after an explicit reset", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "scroll", + isAtEnd: true, + userScrollSessionActive: true, + }), + ).toBe(true); + expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts new file mode 100644 index 00000000000..06de2d9226d --- /dev/null +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -0,0 +1,25 @@ +export type ThreadFeedLiveFollowEvent = + | { readonly type: "reset" } + | { readonly type: "user-scroll-begin" } + | { + readonly type: "scroll"; + readonly isAtEnd: boolean; + readonly userScrollSessionActive: boolean; + }; + +export function resolveThreadFeedLiveFollow( + current: boolean, + event: ThreadFeedLiveFollowEvent, +): boolean { + switch (event.type) { + case "reset": + return true; + case "user-scroll-begin": + return false; + case "scroll": + if (event.isAtEnd) { + return true; + } + return event.userScrollSessionActive ? false : current; + } +} From 497bd8cabc403d64207464ff01370c553eb374bf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 12:38:46 +0200 Subject: [PATCH 02/46] fix(mobile): keep live follow off during user scroll Co-authored-by: codex --- .../src/features/threads/ThreadFeed.tsx | 28 +++++++++++++------ .../threads/thread-feed-live-follow.test.ts | 20 ++++++++++++- .../threads/thread-feed-live-follow.ts | 8 +++++- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 756a74c3d5b..4a981d5674c 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1474,6 +1474,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // this handler, so getState() is current. Only the actual end re-arms // follow: its broader maintain-scroll threshold is large enough for a // streaming chunk to pull a user back before their upward drag escapes. + // A live user-scroll session still wins even if the first scroll event + // remains inside LegendList's at-end tolerance. const listState = props.listRef.current?.getState(); if (listState) { transitionEndFollow({ @@ -1491,20 +1493,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // maintainScrollAtEnd between touch-down and the drag leaving its threshold. transitionEndFollow({ type: "user-scroll-begin" }); }, [transitionEndFollow]); + const finishUserScroll = useCallback(() => { + userScrollSessionRef.current = false; + transitionEndFollow({ + type: "user-scroll-end", + isAtEnd: props.listRef.current?.getState().isAtEnd ?? false, + }); + }, [props.listRef, transitionEndFollow]); // The session must survive past finger-lift so momentum that carries the // user away from the end still breaks follow; a drag released with no // momentum ends its session at the release itself, otherwise at momentum // end. Leaving a session open would let a later animated maintain-scroll // read as user motion and break follow spuriously. - const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent) => { - const velocity = event.nativeEvent.velocity?.y ?? 0; - if (Math.abs(velocity) < 0.05) { - userScrollSessionRef.current = false; - } - }, []); - const handleMomentumScrollEnd = useCallback(() => { - userScrollSessionRef.current = false; - }, []); + const handleScrollEndDrag = useCallback( + (event: NativeSyntheticEvent) => { + const velocity = event.nativeEvent.velocity?.y ?? 0; + if (Math.abs(velocity) < 0.05) { + finishUserScroll(); + } + }, + [finishUserScroll], + ); + const handleMomentumScrollEnd = finishUserScroll; const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 46894da17f9..6d31be30599 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -27,14 +27,32 @@ describe("resolveThreadFeedLiveFollow", () => { ).toBe(true); }); - it("re-arms only at the actual end or after an explicit reset", () => { + it("does not re-arm at the end while a user scroll session is active", () => { expect( resolveThreadFeedLiveFollow(false, { type: "scroll", isAtEnd: true, userScrollSessionActive: true, }), + ).toBe(false); + }); + + it("re-arms at the actual end only after the user scroll session ends", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "user-scroll-end", + isAtEnd: true, + }), ).toBe(true); + expect( + resolveThreadFeedLiveFollow(false, { + type: "user-scroll-end", + isAtEnd: false, + }), + ).toBe(false); + }); + + it("re-arms after an explicit reset", () => { expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true); }); }); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 06de2d9226d..511e3685709 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -1,6 +1,7 @@ export type ThreadFeedLiveFollowEvent = | { readonly type: "reset" } | { readonly type: "user-scroll-begin" } + | { readonly type: "user-scroll-end"; readonly isAtEnd: boolean } | { readonly type: "scroll"; readonly isAtEnd: boolean; @@ -16,10 +17,15 @@ export function resolveThreadFeedLiveFollow( return true; case "user-scroll-begin": return false; + case "user-scroll-end": + return event.isAtEnd; case "scroll": + if (event.userScrollSessionActive) { + return false; + } if (event.isAtEnd) { return true; } - return event.userScrollSessionActive ? false : current; + return current; } } From c78d5e029f6d15c51ed97d611d21bb3c8d525d21 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 12:48:32 +0200 Subject: [PATCH 03/46] fix(mobile): ignore programmatic momentum end Co-authored-by: codex --- apps/mobile/src/features/threads/ThreadFeed.tsx | 2 ++ .../features/threads/thread-feed-live-follow.test.ts | 12 ++++++++++++ .../src/features/threads/thread-feed-live-follow.ts | 8 ++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4a981d5674c..081c61ff232 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1494,10 +1494,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { transitionEndFollow({ type: "user-scroll-begin" }); }, [transitionEndFollow]); const finishUserScroll = useCallback(() => { + const userScrollSessionActive = userScrollSessionRef.current; userScrollSessionRef.current = false; transitionEndFollow({ type: "user-scroll-end", isAtEnd: props.listRef.current?.getState().isAtEnd ?? false, + userScrollSessionActive, }); }, [props.listRef, transitionEndFollow]); // The session must survive past finger-lift so momentum that carries the diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 6d31be30599..8cc68cb3c52 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -42,16 +42,28 @@ describe("resolveThreadFeedLiveFollow", () => { resolveThreadFeedLiveFollow(false, { type: "user-scroll-end", isAtEnd: true, + userScrollSessionActive: true, }), ).toBe(true); expect( resolveThreadFeedLiveFollow(false, { type: "user-scroll-end", isAtEnd: false, + userScrollSessionActive: true, }), ).toBe(false); }); + it("ignores momentum-end events from programmatic scrolling", () => { + expect( + resolveThreadFeedLiveFollow(true, { + type: "user-scroll-end", + isAtEnd: false, + userScrollSessionActive: false, + }), + ).toBe(true); + }); + it("re-arms after an explicit reset", () => { expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true); }); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 511e3685709..babe18f0c1c 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -1,7 +1,11 @@ export type ThreadFeedLiveFollowEvent = | { readonly type: "reset" } | { readonly type: "user-scroll-begin" } - | { readonly type: "user-scroll-end"; readonly isAtEnd: boolean } + | { + readonly type: "user-scroll-end"; + readonly isAtEnd: boolean; + readonly userScrollSessionActive: boolean; + } | { readonly type: "scroll"; readonly isAtEnd: boolean; @@ -18,7 +22,7 @@ export function resolveThreadFeedLiveFollow( case "user-scroll-begin": return false; case "user-scroll-end": - return event.isAtEnd; + return event.userScrollSessionActive ? event.isAtEnd : current; case "scroll": if (event.userScrollSessionActive) { return false; From 6495a84280e73576e33c7236598f27710cbfe745 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 13:02:58 +0200 Subject: [PATCH 04/46] fix(mobile): settle follow after native momentum Co-authored-by: codex --- .../src/features/threads/ThreadFeed.tsx | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 081c61ff232..99a1c79f259 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1328,6 +1328,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); + const userScrollSettleTimerRef = useRef | null>(null); const { width: windowWidth } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); const [viewportWidth, setViewportWidth] = useState(() => @@ -1487,13 +1488,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], ); + const clearUserScrollSettle = useCallback(() => { + if (userScrollSettleTimerRef.current !== null) { + clearTimeout(userScrollSettleTimerRef.current); + userScrollSettleTimerRef.current = null; + } + }, []); const handleScrollBeginDrag = useCallback(() => { + clearUserScrollSettle(); userScrollSessionRef.current = true; // Pause before the first scroll event. Otherwise a stream update can run // maintainScrollAtEnd between touch-down and the drag leaving its threshold. transitionEndFollow({ type: "user-scroll-begin" }); - }, [transitionEndFollow]); + }, [clearUserScrollSettle, transitionEndFollow]); const finishUserScroll = useCallback(() => { + clearUserScrollSettle(); const userScrollSessionActive = userScrollSessionRef.current; userScrollSessionRef.current = false; transitionEndFollow({ @@ -1501,23 +1510,25 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { isAtEnd: props.listRef.current?.getState().isAtEnd ?? false, userScrollSessionActive, }); - }, [props.listRef, transitionEndFollow]); - // The session must survive past finger-lift so momentum that carries the - // user away from the end still breaks follow; a drag released with no - // momentum ends its session at the release itself, otherwise at momentum - // end. Leaving a session open would let a later animated maintain-scroll - // read as user motion and break follow spuriously. - const handleScrollEndDrag = useCallback( - (event: NativeSyntheticEvent) => { - const velocity = event.nativeEvent.velocity?.y ?? 0; - if (Math.abs(velocity) < 0.05) { - finishUserScroll(); - } - }, - [finishUserScroll], - ); + }, [clearUserScrollSettle, props.listRef, transitionEndFollow]); + // Finger-lift velocity is not a reliable momentum signal: a gentle fling + // can report zero and still decelerate. Give native momentum a short window + // to announce itself; if it does, onMomentumScrollBegin cancels this fallback + // and the session survives until the settled momentum-end position. This + // mirrors the native-event handoff used by the home thread list's scroll gate. + const handleScrollEndDrag = useCallback(() => { + clearUserScrollSettle(); + userScrollSettleTimerRef.current = setTimeout(finishUserScroll, 160); + }, [clearUserScrollSettle, finishUserScroll]); + const handleMomentumScrollBegin = useCallback(() => { + if (userScrollSessionRef.current) { + clearUserScrollSettle(); + } + }, [clearUserScrollSettle]); const handleMomentumScrollEnd = finishUserScroll; + useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); + const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); const nextHeight = Math.round(event.nativeEvent.layout.height); @@ -1533,15 +1544,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // live edge (ThreadDetailScreen scrolls the new message into place). Both // re-arm follow regardless of where the user had scrolled before. useEffect(() => { + clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); - }, [props.threadId, transitionEndFollow]); + }, [clearUserScrollSettle, props.threadId, transitionEndFollow]); useEffect(() => { if (props.anchorMessageId !== null) { + clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); } - }, [props.anchorMessageId, transitionEndFollow]); + }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); @@ -1944,6 +1957,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onScroll={handleScroll} onScrollBeginDrag={handleScrollBeginDrag} onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} ListHeaderComponent={ From 884cd7da97ea6705353a7f70effd0dd38975bb26 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 13:14:43 +0200 Subject: [PATCH 05/46] fix(mobile): preserve follow at drag release Co-authored-by: codex --- .../src/features/threads/ThreadFeed.tsx | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 99a1c79f259..ede1b2b653f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1501,16 +1501,22 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // maintainScrollAtEnd between touch-down and the drag leaving its threshold. transitionEndFollow({ type: "user-scroll-begin" }); }, [clearUserScrollSettle, transitionEndFollow]); - const finishUserScroll = useCallback(() => { - clearUserScrollSettle(); - const userScrollSessionActive = userScrollSessionRef.current; - userScrollSessionRef.current = false; - transitionEndFollow({ - type: "user-scroll-end", - isAtEnd: props.listRef.current?.getState().isAtEnd ?? false, - userScrollSessionActive, - }); - }, [clearUserScrollSettle, props.listRef, transitionEndFollow]); + const finishUserScroll = useCallback( + (releaseIsAtEnd?: boolean) => { + clearUserScrollSettle(); + const userScrollSessionActive = userScrollSessionRef.current; + userScrollSessionRef.current = false; + transitionEndFollow({ + type: "user-scroll-end", + // With no momentum, preserve the finger-release position. Streaming + // growth during the native momentum-detection window must not turn a + // release at the live edge into an opt-out from follow. + isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false, + userScrollSessionActive, + }); + }, + [clearUserScrollSettle, props.listRef, transitionEndFollow], + ); // Finger-lift velocity is not a reliable momentum signal: a gentle fling // can report zero and still decelerate. Give native momentum a short window // to announce itself; if it does, onMomentumScrollBegin cancels this fallback @@ -1518,14 +1524,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // mirrors the native-event handoff used by the home thread list's scroll gate. const handleScrollEndDrag = useCallback(() => { clearUserScrollSettle(); - userScrollSettleTimerRef.current = setTimeout(finishUserScroll, 160); - }, [clearUserScrollSettle, finishUserScroll]); + const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false; + userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160); + }, [clearUserScrollSettle, finishUserScroll, props.listRef]); const handleMomentumScrollBegin = useCallback(() => { if (userScrollSessionRef.current) { clearUserScrollSettle(); } }, [clearUserScrollSettle]); - const handleMomentumScrollEnd = finishUserScroll; + const handleMomentumScrollEnd = useCallback(() => { + finishUserScroll(); + }, [finishUserScroll]); useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); From b70fee6113168760a7302d1257f49b1c82d9cbc4 Mon Sep 17 00:00:00 2001 From: Thuong Tin Date: Fri, 31 Jul 2026 19:37:42 +0700 Subject: [PATCH 06/46] fix(mobile): handle long multi-select user input forms Co-authored-by: codex --- .../features/threads/PendingUserInputCard.tsx | 129 ++++++++++-------- .../features/threads/ThreadDetailScreen.tsx | 5 +- apps/mobile/src/lib/threadActivity.test.ts | 72 ++++++++++ apps/mobile/src/lib/threadActivity.ts | 63 +++++++-- .../src/state/use-selected-thread-requests.ts | 25 +++- 5 files changed, 220 insertions(+), 74 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index c3c9b4e7ce8..54f4ea28117 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,5 +1,5 @@ -import type { ApprovalRequestId } from "@t3tools/contracts"; -import { Pressable, View } from "react-native"; +import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; +import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { cn } from "../../lib/cn"; @@ -8,11 +8,11 @@ import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/th export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; readonly drafts: Record; - readonly answers: Record | null; + readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly onSelectOption: ( requestId: ApprovalRequestId, - questionId: string, + question: UserInputQuestion, label: string, ) => void; readonly onChangeCustomAnswer: ( @@ -24,73 +24,90 @@ export interface PendingUserInputCardProps { } export function PendingUserInputCard(props: PendingUserInputCardProps) { + const { height: windowHeight } = useWindowDimensions(); + const maxHeight = Math.max(180, Math.min(560, Math.floor(windowHeight * 0.62))); + // The surface is opaque on purpose: the card floats over the thread feed // with no blur behind it, so a translucent background renders the questions // on top of whatever message happens to sit underneath. return ( - + User input needed Fill in the pending answers - {props.pendingUserInput.questions.map((question) => { - const draft = props.drafts[question.id]; - return ( - - - {question.header} - - - {question.question} - - - {question.options.map((option) => { - const selected = - draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; - return ( - - props.onSelectOption( - props.pendingUserInput.requestId, - question.id, - option.label, - ) - } - > - + {props.pendingUserInput.questions.map((question) => { + const draft = props.drafts[question.id]; + return ( + + + {question.header} + + + {question.question} + + + {question.options.map((option) => { + const selected = + draft?.selectedOptionLabels?.includes(option.label) === true && + !draft.customAnswer?.trim().length; + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question, + option.label, + ) + } > - {option.label} - - - ); - })} + + {option.label} + + + ); + })} + + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + /> - - props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) - } - placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" - /> - - ); - })} + ); + })} + ; - readonly activePendingUserInputAnswers: Record | null; + readonly activePendingUserInputAnswers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; @@ -93,7 +94,7 @@ export interface ThreadDetailScreenProps { ) => Promise; readonly onSelectUserInputOption: ( requestId: ApprovalRequestId, - questionId: string, + question: UserInputQuestion, label: string, ) => void; readonly onChangeUserInputCustomAnswer: ( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ae9a93e9fc3..730849fc968 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -12,12 +12,84 @@ import { } from "@t3tools/contracts"; import { + buildPendingUserInputAnswers, buildThreadFeed, deriveThreadFeedPresentation, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; +const singleSelectQuestion = { + id: "runtime", + header: "Runtime", + question: "Which runtime should be used?", + options: [ + { label: "Go", description: "One binary" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const multiSelectQuestion = { + id: "scope", + header: "Scope", + question: "Which data should be collected?", + options: [ + { label: "Orders", description: "Receipts" }, + { label: "Listings", description: "Inventory" }, + ], + multiSelect: true, +} as const; + +describe("pending user input answers", () => { + it("replaces single-select options and toggles multi-select options", () => { + expect( + togglePendingUserInputOptionSelection( + singleSelectQuestion, + { selectedOptionLabels: ["Go"] }, + "Node.js", + ), + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] }); + + const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); + const ordersAndListings = togglePendingUserInputOptionSelection( + multiSelectQuestion, + orders, + "Listings", + ); + expect(ordersAndListings).toEqual({ + customAnswer: "", + selectedOptionLabels: ["Orders", "Listings"], + }); + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] }); + }); + + it("builds array answers for multi-select questions", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { + runtime: { selectedOptionLabels: ["Go"] }, + scope: { selectedOptionLabels: ["Orders", "Listings"] }, + }), + ).toEqual({ + runtime: "Go", + scope: ["Orders", "Listings"], + }); + }); + + it("clears selected options while a custom answer is active", () => { + expect( + setPendingUserInputCustomAnswer( + { selectedOptionLabels: ["Orders", "Listings"] }, + "Orders first", + ), + ).toEqual({ customAnswer: "Orders first" }); + }); +}); + function makeActivity( input: Partial & Pick, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cd8e8cad212..687d32a3c15 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -26,7 +26,7 @@ export interface PendingUserInput { } export interface PendingUserInputDraftAnswer { - readonly selectedOptionLabel?: string; + readonly selectedOptionLabels?: ReadonlyArray; readonly customAnswer?: string; } @@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeSelectedOptionLabels( + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), + ); +} + function resolvePendingUserInputAnswer( + question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, -): string | null { +): string | ReadonlyArray | null { const customAnswer = normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } - return normalizeDraftAnswer(draft?.selectedOptionLabel); + + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + if (question.multiSelect) { + return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; + } + return selectedOptionLabels[0] ?? null; } /** Codex children settle via task.updated (idle/failed/interrupted), never @@ -1428,22 +1446,49 @@ export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabel = - customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + const selectedOptionLabels = + customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); return { customAnswer, - ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + }; +} + +export function togglePendingUserInputOptionSelection( + question: UserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): PendingUserInputDraftAnswer { + if (question.multiSelect) { + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const nextSelectedOptionLabels = selectedOptionLabels.includes(optionLabel) + ? selectedOptionLabels.filter((label) => label !== optionLabel) + : [...selectedOptionLabels, optionLabel]; + + return { + customAnswer: "", + ...(nextSelectedOptionLabels.length > 0 + ? { selectedOptionLabels: nextSelectedOptionLabels } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionLabels: [optionLabel], }; } export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, -): Record | null { - const answers: Record = {}; +): Record> | null { + const answers: Record> = {}; for (const question of questions) { - const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) { return null; } diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 82ff42f247a..30b3a0704f8 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { + ApprovalRequestId, + type ProviderApprovalDecision, + type UserInputQuestion, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { threadEnvironment } from "../state/threads"; @@ -12,6 +16,7 @@ import { derivePendingUserInputs, setPendingUserInputCustomAnswer, sortThreadActivities, + togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; import { appAtomRegistry } from "./atom-registry"; @@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); -function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { +function setUserInputDraftOption( + requestKey: string, + question: UserInputQuestion, + label: string, +): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...current, [requestKey]: { ...current[requestKey], - [questionId]: { - selectedOptionLabel: label, - }, + [question.id]: togglePendingUserInputOptionSelection( + question, + current[requestKey]?.[question.id], + label, + ), }, }); } @@ -97,13 +108,13 @@ export function useSelectedThreadRequests() { : null; const onSelectUserInputOption = useCallback( - (requestId: ApprovalRequestId, questionId: string, label: string) => { + (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => { if (!selectedThreadShell) { return; } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); - setUserInputDraftOption(requestKey, questionId, label); + setUserInputDraftOption(requestKey, question, label); }, [selectedThreadShell], ); From 5bada3f07749f749bbb6ead2f9a14d9073f7092e Mon Sep 17 00:00:00 2001 From: Thuong Tin Date: Fri, 31 Jul 2026 19:44:51 +0700 Subject: [PATCH 07/46] fix(mobile): normalize user input option labels Co-authored-by: codex --- apps/mobile/src/lib/threadActivity.test.ts | 10 ++++++++++ apps/mobile/src/lib/threadActivity.ts | 10 ++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 730849fc968..b7880011861 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -66,6 +66,16 @@ describe("pending user input answers", () => { expect( togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] }); + + const paddedOrders = togglePendingUserInputOptionSelection( + multiSelectQuestion, + undefined, + " Orders ", + ); + expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] }); + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), + ).toEqual({ customAnswer: "" }); }); it("builds array answers for multi-select questions", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 687d32a3c15..979a79930b8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1461,11 +1461,13 @@ export function togglePendingUserInputOptionSelection( draft: PendingUserInputDraftAnswer | undefined, optionLabel: string, ): PendingUserInputDraftAnswer { + const normalizedOptionLabel = optionLabel.trim(); + if (question.multiSelect) { const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); - const nextSelectedOptionLabels = selectedOptionLabels.includes(optionLabel) - ? selectedOptionLabels.filter((label) => label !== optionLabel) - : [...selectedOptionLabels, optionLabel]; + const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel) + ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel) + : [...selectedOptionLabels, normalizedOptionLabel]; return { customAnswer: "", @@ -1477,7 +1479,7 @@ export function togglePendingUserInputOptionSelection( return { customAnswer: "", - selectedOptionLabels: [optionLabel], + selectedOptionLabels: [normalizedOptionLabel], }; } From 523bf34d0f2ac060458b8b3d5a8e127ab3d52ea1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 12:52:49 +0200 Subject: [PATCH 08/46] fix(mobile): keep pending input above keyboard Co-authored-by: codex --- .../features/threads/PendingUserInputCard.tsx | 10 ++--- .../features/threads/ThreadDetailScreen.tsx | 25 +++++++++++- .../threads/pendingUserInputLayout.test.ts | 38 +++++++++++++++++++ .../threads/pendingUserInputLayout.ts | 18 +++++++++ 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/features/threads/pendingUserInputLayout.test.ts create mode 100644 apps/mobile/src/features/threads/pendingUserInputLayout.ts diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 54f4ea28117..8d84ed1ba73 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,5 +1,5 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; -import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; +import { Pressable, ScrollView, View } from "react-native"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { cn } from "../../lib/cn"; @@ -7,6 +7,7 @@ import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/th export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; + readonly maxHeight: number; readonly drafts: Record; readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; @@ -24,16 +25,13 @@ export interface PendingUserInputCardProps { } export function PendingUserInputCard(props: PendingUserInputCardProps) { - const { height: windowHeight } = useWindowDimensions(); - const maxHeight = Math.max(180, Math.min(560, Math.floor(windowHeight * 0.62))); - // The surface is opaque on purpose: the card floats over the thread feed // with no blur behind it, so a translucent background renders the questions // on top of whatever message happens to sit underneath. return ( User input needed diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index c19149cffaf..80dfb170197 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -2,6 +2,7 @@ import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; import { useKeyboardChatComposerInset, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; +import { HeaderHeightContext } from "@react-navigation/elements"; import type { ApprovalRequestId, EnvironmentId, @@ -16,8 +17,17 @@ import type { UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Platform, View, type GestureResponderEvent } from "react-native"; +import { + memo, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { Platform, useWindowDimensions, View, type GestureResponderEvent } from "react-native"; import { KeyboardController, KeyboardStickyView, @@ -39,6 +49,7 @@ import type { } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; import { PendingUserInputCard } from "./PendingUserInputCard"; +import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout"; import { COMPOSER_COLLAPSED_CHROME, COMPOSER_EXPANDED_CHROME, @@ -179,6 +190,9 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray state.isVisible); + const windowHeight = useWindowDimensions().height; + const keyboardHeight = useKeyboardState((state) => state.height); + const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); @@ -210,6 +224,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; + const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ + windowHeight, + keyboardHeight: isKeyboardVisible ? keyboardHeight : 0, + navigationHeaderHeight, + composerOverlapHeight, + }); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -417,6 +437,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingUserInput ? ( { + it("caps a tall portrait viewport", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 932, + keyboardHeight: 0, + navigationHeaderHeight: 103, + composerOverlapHeight: 94, + }), + ).toBe(560); + }); + + it("subtracts the keyboard while editing a custom answer", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 932, + keyboardHeight: 336, + navigationHeaderHeight: 103, + composerOverlapHeight: 94, + }), + ).toBe(387); + }); + + it("never forces the card beyond a short landscape viewport", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 375, + keyboardHeight: 240, + navigationHeaderHeight: 44, + composerOverlapHeight: 94, + }), + ).toBe(0); + }); +}); diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts new file mode 100644 index 00000000000..0d5174e150e --- /dev/null +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -0,0 +1,18 @@ +const PENDING_USER_INPUT_MAX_HEIGHT = 560; +const PENDING_USER_INPUT_VERTICAL_GAP = 12; + +export function derivePendingUserInputMaxHeight(input: { + readonly windowHeight: number; + readonly keyboardHeight: number; + readonly navigationHeaderHeight: number; + readonly composerOverlapHeight: number; +}): number { + const availableHeight = + input.windowHeight - + Math.max(0, input.keyboardHeight) - + Math.max(0, input.navigationHeaderHeight) - + Math.max(0, input.composerOverlapHeight) - + PENDING_USER_INPUT_VERTICAL_GAP; + + return Math.min(PENDING_USER_INPUT_MAX_HEIGHT, Math.max(0, availableHeight)); +} From b3823ec2168de55606c8e3833388600278346cf6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 13:14:55 +0200 Subject: [PATCH 09/46] fix(mobile): keep pending input actions usable Co-authored-by: codex --- .../src/features/threads/PendingUserInputCard.tsx | 10 ++++++---- .../features/threads/pendingUserInputLayout.test.ts | 4 ++-- .../src/features/threads/pendingUserInputLayout.ts | 6 +++++- apps/mobile/src/lib/threadActivity.test.ts | 13 +++++++++++++ apps/mobile/src/lib/threadActivity.ts | 11 +++++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 8d84ed1ba73..1dc28a06fa8 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -3,7 +3,11 @@ import { Pressable, ScrollView, View } from "react-native"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { cn } from "../../lib/cn"; -import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; +import { + isPendingUserInputOptionSelected, + type PendingUserInput, + type PendingUserInputDraftAnswer, +} from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; @@ -60,9 +64,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {question.options.map((option) => { - const selected = - draft?.selectedOptionLabels?.includes(option.label) === true && - !draft.customAnswer?.trim().length; + const selected = isPendingUserInputOptionSelected(draft, option.label); return ( { ).toBe(387); }); - it("never forces the card beyond a short landscape viewport", () => { + it("keeps the fixed action area usable in a short keyboard-open viewport", () => { expect( derivePendingUserInputMaxHeight({ windowHeight: 375, @@ -33,6 +33,6 @@ describe("derivePendingUserInputMaxHeight", () => { navigationHeaderHeight: 44, composerOverlapHeight: 94, }), - ).toBe(0); + ).toBe(160); }); }); diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts index 0d5174e150e..631e3d7713a 100644 --- a/apps/mobile/src/features/threads/pendingUserInputLayout.ts +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -1,4 +1,5 @@ const PENDING_USER_INPUT_MAX_HEIGHT = 560; +const PENDING_USER_INPUT_MIN_HEIGHT = 160; const PENDING_USER_INPUT_VERTICAL_GAP = 12; export function derivePendingUserInputMaxHeight(input: { @@ -14,5 +15,8 @@ export function derivePendingUserInputMaxHeight(input: { Math.max(0, input.composerOverlapHeight) - PENDING_USER_INPUT_VERTICAL_GAP; - return Math.min(PENDING_USER_INPUT_MAX_HEIGHT, Math.max(0, availableHeight)); + return Math.min( + PENDING_USER_INPUT_MAX_HEIGHT, + Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight), + ); } diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index b7880011861..e1d46fd858e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -15,6 +15,7 @@ import { buildPendingUserInputAnswers, buildThreadFeed, deriveThreadFeedPresentation, + isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, type ThreadFeedActivity, @@ -98,6 +99,18 @@ describe("pending user input answers", () => { ), ).toEqual({ customAnswer: "Orders first" }); }); + + it("matches selected chips against normalized option labels", () => { + expect( + isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "), + ).toBe(true); + expect( + isPendingUserInputOptionSelected( + { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" }, + " Orders ", + ), + ).toBe(false); + }); }); function makeActivity( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 979a79930b8..fbcb2e1c7e2 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1456,6 +1456,17 @@ export function setPendingUserInputCustomAnswer( }; } +export function isPendingUserInputOptionSelected( + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): boolean { + if (normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim()); +} + export function togglePendingUserInputOptionSelection( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, From e97b090ea517b01c028ca3d6651b7f654c71065f Mon Sep 17 00:00:00 2001 From: Kapish14 Date: Mon, 10 Aug 2026 04:50:19 +0530 Subject: [PATCH 10/46] feat(mobile): add scroll-to-end button Co-authored-by: codex --- apps/mobile/src/components/ControlPill.tsx | 27 ++++++++- .../features/threads/ThreadDetailScreen.tsx | 56 +++++++++++++++++++ .../src/features/threads/ThreadFeed.tsx | 19 ++++--- .../threads/thread-end-follow-state.test.ts | 32 +++++++++++ .../threads/thread-end-follow-state.ts | 30 ++++++++++ docs/user/threads.md | 5 ++ 6 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-end-follow-state.test.ts create mode 100644 apps/mobile/src/features/threads/thread-end-follow-state.ts create mode 100644 docs/user/threads.md diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 587abcc06f5..abcfc7f7b80 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -6,6 +6,7 @@ import { type ComponentProps, type ReactElement, type ReactNode, + useRef, } from "react"; import { Platform, Pressable, useColorScheme, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; @@ -21,10 +22,31 @@ export function ControlPill(props: { readonly label?: string; readonly accessibilityLabel?: string; readonly onPress?: () => void; + readonly activateOnPressIn?: boolean; readonly variant?: "circle" | "pill" | "primary" | "danger"; readonly disabled?: boolean; + readonly className?: string; }) { const variant = props.variant ?? "circle"; + const activatedOnPressInRef = useRef(false); + + const handlePressIn = () => { + activatedOnPressInRef.current = true; + props.onPress?.(); + }; + const handlePressOut = () => { + // Pressability invokes onPressOut immediately before onPress on release. + // Defer the reset so onPress can identify the same physical gesture. + setTimeout(() => { + activatedOnPressInRef.current = false; + }, 0); + }; + const handlePress = () => { + if (activatedOnPressInRef.current) { + return; + } + props.onPress?.(); + }; const iconColor = useThemeColor("--color-icon"); const iconSubtle = useThemeColor("--color-icon-subtle"); @@ -54,6 +76,7 @@ export function ControlPill(props: { : variant === "danger" ? "bg-danger" : "bg-subtle", + props.className, ); const labelClassName = cn( "text-center text-xs font-t3-bold", @@ -68,7 +91,9 @@ export function ControlPill(props: { diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 80dfb170197..edc7da49513 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -36,6 +36,7 @@ import { import Animated, { FadeInDown, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { ControlPill } from "../../components/ControlPill"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; @@ -56,6 +57,11 @@ import { ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; +import { + initialThreadEndFollowState, + reduceThreadEndFollowState, + threadEndFollowEnabled, +} from "./thread-end-follow-state"; import type { ThreadContentPresentation } from "./threadContentPresentation"; export interface ThreadDetailScreenProps { @@ -203,6 +209,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); + const [endFollowState, setEndFollowState] = useState(() => + initialThreadEndFollowState(selectedThreadKey), + ); + const endFollowEnabled = threadEndFollowEnabled(endFollowState, selectedThreadKey); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or @@ -338,6 +348,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread composerEditorRef.current?.blur(); }, []); + const handleEndFollowEnabledChange = useCallback( + (enabled: boolean) => { + setEndFollowState((current) => + reduceThreadEndFollowState(current, { + type: "observed", + threadKey: selectedThreadKey, + enabled, + }), + ); + }, + [selectedThreadKey], + ); + + const handleScrollToEnd = useCallback(() => { + setEndFollowState((current) => + reduceThreadEndFollowState(current, { + type: "scrollToEnd", + threadKey: selectedThreadKey, + }), + ); + void Haptics.selectionAsync(); + void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { + freeze.set(false); + }); + }, [freeze, scrollMessageToEnd, selectedThreadKey]); + + const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; + const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { feedTouchStartRef.current = { pageX: event.nativeEvent.pageX, @@ -398,6 +436,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} + endFollowEnabled={endFollowEnabled} + onEndFollowEnabledChange={handleEndFollowEnabledChange} skills={selectedProviderSkills} loadEarlier={props.loadEarlier ?? null} /> @@ -420,6 +460,22 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} + {showScrollToEndButton ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( void; + readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { @@ -1347,13 +1348,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // momentum; only motion inside a session can break follow, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); - const setEndFollow = useCallback((enabled: boolean) => { - if (endFollowEnabledRef.current === enabled) { - return; - } - endFollowEnabledRef.current = enabled; - setEndFollowEnabled(enabled); - }, []); + const setEndFollow = useCallback( + (enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + props.onEndFollowEnabledChange?.(enabled); + }, + [props.onEndFollowEnabledChange], + ); const transitionEndFollow = useCallback( (event: ThreadFeedLiveFollowEvent) => { setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); diff --git a/apps/mobile/src/features/threads/thread-end-follow-state.test.ts b/apps/mobile/src/features/threads/thread-end-follow-state.test.ts new file mode 100644 index 00000000000..cd6ca2ddce3 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-end-follow-state.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + initialThreadEndFollowState, + reduceThreadEndFollowState, + threadEndFollowEnabled, +} from "./thread-end-follow-state"; + +describe("thread end-follow state", () => { + it("starts a newly selected thread with end-follow enabled", () => { + const previousThreadState = reduceThreadEndFollowState( + initialThreadEndFollowState("thread-a"), + { type: "observed", threadKey: "thread-a", enabled: false }, + ); + + expect(threadEndFollowEnabled(previousThreadState, "thread-b")).toBe(true); + }); + + it("re-arms end-follow before scrolling to the end", () => { + const scrolledAwayState = reduceThreadEndFollowState(initialThreadEndFollowState("thread-a"), { + type: "observed", + threadKey: "thread-a", + enabled: false, + }); + const scrollToEndState = reduceThreadEndFollowState(scrolledAwayState, { + type: "scrollToEnd", + threadKey: "thread-a", + }); + + expect(threadEndFollowEnabled(scrollToEndState, "thread-a")).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-end-follow-state.ts b/apps/mobile/src/features/threads/thread-end-follow-state.ts new file mode 100644 index 00000000000..50bde318d3c --- /dev/null +++ b/apps/mobile/src/features/threads/thread-end-follow-state.ts @@ -0,0 +1,30 @@ +export type ThreadEndFollowState = { + readonly threadKey: string; + readonly enabled: boolean; +}; + +export type ThreadEndFollowEvent = + | { + readonly type: "observed"; + readonly threadKey: string; + readonly enabled: boolean; + } + | { readonly type: "scrollToEnd"; readonly threadKey: string }; + +export function initialThreadEndFollowState(threadKey: string): ThreadEndFollowState { + return { threadKey, enabled: true }; +} + +export function threadEndFollowEnabled(state: ThreadEndFollowState, threadKey: string): boolean { + return state.threadKey === threadKey ? state.enabled : true; +} + +export function reduceThreadEndFollowState( + state: ThreadEndFollowState, + event: ThreadEndFollowEvent, +): ThreadEndFollowState { + if (event.type === "observed") { + return { threadKey: event.threadKey, enabled: event.enabled }; + } + return { threadKey: event.threadKey, enabled: true }; +} diff --git a/docs/user/threads.md b/docs/user/threads.md new file mode 100644 index 00000000000..5d71264b5ed --- /dev/null +++ b/docs/user/threads.md @@ -0,0 +1,5 @@ +# Reading threads + +On mobile, scrolling away from the latest activity reveals a down-arrow button above the message +composer. Tap it to return to the end of the thread. The button disappears when the latest activity +is visible again. From 8f308a2896c45a3b2bf4fed8da8f384923ad3188 Mon Sep 17 00:00:00 2001 From: Kapish14 Date: Mon, 10 Aug 2026 05:38:17 +0530 Subject: [PATCH 11/46] revert(mobile): drop follow-state review changes Co-authored-by: codex --- .../features/threads/ThreadDetailScreen.tsx | 35 +++---------------- .../threads/thread-end-follow-state.test.ts | 32 ----------------- .../threads/thread-end-follow-state.ts | 30 ---------------- 3 files changed, 4 insertions(+), 93 deletions(-) delete mode 100644 apps/mobile/src/features/threads/thread-end-follow-state.test.ts delete mode 100644 apps/mobile/src/features/threads/thread-end-follow-state.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index edc7da49513..40a06c206be 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -57,11 +57,6 @@ import { ThreadComposer, } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; -import { - initialThreadEndFollowState, - reduceThreadEndFollowState, - threadEndFollowEnabled, -} from "./thread-end-follow-state"; import type { ThreadContentPresentation } from "./threadContentPresentation"; export interface ThreadDetailScreenProps { @@ -209,10 +204,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); - const [endFollowState, setEndFollowState] = useState(() => - initialThreadEndFollowState(selectedThreadKey), - ); - const endFollowEnabled = threadEndFollowEnabled(endFollowState, selectedThreadKey); + const [endFollowEnabled, setEndFollowEnabled] = useState(true); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or @@ -277,6 +269,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); lastScrolledAnchorMessageIdRef.current = null; + setEndFollowEnabled(true); freeze.set(false); }, [freeze, selectedThreadKey]); @@ -348,31 +341,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread composerEditorRef.current?.blur(); }, []); - const handleEndFollowEnabledChange = useCallback( - (enabled: boolean) => { - setEndFollowState((current) => - reduceThreadEndFollowState(current, { - type: "observed", - threadKey: selectedThreadKey, - enabled, - }), - ); - }, - [selectedThreadKey], - ); - const handleScrollToEnd = useCallback(() => { - setEndFollowState((current) => - reduceThreadEndFollowState(current, { - type: "scrollToEnd", - threadKey: selectedThreadKey, - }), - ); void Haptics.selectionAsync(); void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { freeze.set(false); }); - }, [freeze, scrollMessageToEnd, selectedThreadKey]); + }, [freeze, scrollMessageToEnd]); const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; @@ -436,8 +410,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} - endFollowEnabled={endFollowEnabled} - onEndFollowEnabledChange={handleEndFollowEnabledChange} + onEndFollowEnabledChange={setEndFollowEnabled} skills={selectedProviderSkills} loadEarlier={props.loadEarlier ?? null} /> diff --git a/apps/mobile/src/features/threads/thread-end-follow-state.test.ts b/apps/mobile/src/features/threads/thread-end-follow-state.test.ts deleted file mode 100644 index cd6ca2ddce3..00000000000 --- a/apps/mobile/src/features/threads/thread-end-follow-state.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; - -import { - initialThreadEndFollowState, - reduceThreadEndFollowState, - threadEndFollowEnabled, -} from "./thread-end-follow-state"; - -describe("thread end-follow state", () => { - it("starts a newly selected thread with end-follow enabled", () => { - const previousThreadState = reduceThreadEndFollowState( - initialThreadEndFollowState("thread-a"), - { type: "observed", threadKey: "thread-a", enabled: false }, - ); - - expect(threadEndFollowEnabled(previousThreadState, "thread-b")).toBe(true); - }); - - it("re-arms end-follow before scrolling to the end", () => { - const scrolledAwayState = reduceThreadEndFollowState(initialThreadEndFollowState("thread-a"), { - type: "observed", - threadKey: "thread-a", - enabled: false, - }); - const scrollToEndState = reduceThreadEndFollowState(scrolledAwayState, { - type: "scrollToEnd", - threadKey: "thread-a", - }); - - expect(threadEndFollowEnabled(scrollToEndState, "thread-a")).toBe(true); - }); -}); diff --git a/apps/mobile/src/features/threads/thread-end-follow-state.ts b/apps/mobile/src/features/threads/thread-end-follow-state.ts deleted file mode 100644 index 50bde318d3c..00000000000 --- a/apps/mobile/src/features/threads/thread-end-follow-state.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type ThreadEndFollowState = { - readonly threadKey: string; - readonly enabled: boolean; -}; - -export type ThreadEndFollowEvent = - | { - readonly type: "observed"; - readonly threadKey: string; - readonly enabled: boolean; - } - | { readonly type: "scrollToEnd"; readonly threadKey: string }; - -export function initialThreadEndFollowState(threadKey: string): ThreadEndFollowState { - return { threadKey, enabled: true }; -} - -export function threadEndFollowEnabled(state: ThreadEndFollowState, threadKey: string): boolean { - return state.threadKey === threadKey ? state.enabled : true; -} - -export function reduceThreadEndFollowState( - state: ThreadEndFollowState, - event: ThreadEndFollowEvent, -): ThreadEndFollowState { - if (event.type === "observed") { - return { threadKey: event.threadKey, enabled: event.enabled }; - } - return { threadKey: event.threadKey, enabled: true }; -} From 73ae4c527ae34f37ac4107f073864affb219b11b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 19:00:24 +0200 Subject: [PATCH 12/46] fix(mobile): keep native header buttons stable across updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilding UIBarButtonItems on every header option change (title, subtitle, status) replaces the iOS 26 glass UIButton custom views while UIKit may be animating one — stranding a menu capsule mid-morph or unmasking the back button's glass capsule into a square. Reuse the applied items when the JS bar-button configs are structurally unchanged, and only rebuild the bottom toolbar when its configs, visibility, or owning screen actually changed. Co-Authored-By: Claude Fable 5 --- patches/react-native-screens@4.25.2.patch | 135 +++++++++++++++------- pnpm-lock.yaml | 106 ++++++++--------- 2 files changed, 145 insertions(+), 96 deletions(-) diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch index 7bd9fb744e9..a1c64eb0331 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.25.2.patch @@ -140,11 +140,24 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm -index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644 +index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce930d9fd7f 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm -@@ -30,6 +30,20 @@ +@@ -25,11 +25,33 @@ + #import "RNSSearchBar.h" + #import "UINavigationBar+RNSUtility.h" + ++#import ++ + namespace react = facebook::react; + static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; ++ ++// Keys for the last-applied JS bar button configs, associated with the ++// navigation item so unrelated header updates (title, subtitle, tint) don't ++// recreate the native buttons they configure. ++static char RNSAppliedHeaderBarButtonConfigsKey; ++static char RNSAppliedToolbarConfigsKey; static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; +static NSInteger navigationItemStyleFromCppEquivalent( @@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @interface RCTImageLoader (Private) - (id)imageCache; @end -@@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string +@@ -47,6 +69,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string @end @interface RNSScreenStackHeaderConfig () @@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @end @implementation RNSScreenStackHeaderConfig { -@@ -81,6 +98,7 @@ - (void)initProps +@@ -81,6 +106,7 @@ - (void)initProps self.hidden = YES; _reactSubviews = [NSMutableArray new]; _backTitleVisible = YES; @@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d _blurEffect = RNSBlurEffectStyleNone; } -@@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc +@@ -496,6 +522,10 @@ + (void)updateViewController:(UIViewController *)vc if (shouldHide) { navitem.title = config.title; @@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. [navctr setNavigationBarHidden:YES animated:animated]; -@@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc +@@ -512,11 +542,19 @@ + (void)updateViewController:(UIViewController *)vc } navitem.largeTitleDisplayMode = config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; @@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV -@@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc +@@ -637,10 +675,322 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -221,32 +234,57 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d - withCurrentItems:navitem.leftBarButtonItems]; - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems - withCurrentItems:navitem.rightBarButtonItems]; -+ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems -+ withCurrentItems:navitem.leftBarButtonItems -+ navigationItem:navitem]; -+ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems -+ withCurrentItems:navitem.rightBarButtonItems -+ navigationItem:navitem]; -+ NSArray *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems -+ withCurrentItems:@[] ++ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; ++ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; ++ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; ++ NSArray *subviewLeftItems = navitem.leftBarButtonItems ?: @[]; ++ NSArray *subviewRightItems = navitem.rightBarButtonItems ?: @[]; ++ NSArray *headerItemsKey = @[ headerLeftConfigs, headerRightConfigs, headerCenterConfigs ]; ++ // Rebuilding bar button items creates brand-new native buttons (glass ++ // UIButton custom views on iOS 26). Replacing them while UIKit animates an ++ // existing one (menu capsule morph, push/pop glass transitions) strands the ++ // animation overlay — a stuck expanded capsule or an unmasked square back ++ // button. When the JS configs are unchanged, keep the already-applied items. ++ // Subview-backed items are re-derived every pass, so their presence forces ++ // the rebuild path. ++ BOOL reuseHeaderBarButtonItems = subviewLeftItems.count == 0 && subviewRightItems.count == 0 && ++ [objc_getAssociatedObject(navitem, &RNSAppliedHeaderBarButtonConfigsKey) isEqual:headerItemsKey]; ++ if (!reuseHeaderBarButtonItems) { ++ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems ++ withCurrentItems:navitem.leftBarButtonItems + navigationItem:navitem]; ++ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems ++ withCurrentItems:navitem.rightBarButtonItems ++ navigationItem:navitem]; ++ NSArray *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems ++ withCurrentItems:@[] ++ navigationItem:navitem]; +#if !TARGET_OS_TV -+ if (@available(iOS 16.0, *)) { -+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; -+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; -+ if (@available(iOS 26.0, *)) { -+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; ++ if (@available(iOS 16.0, *)) { ++ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; ++ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; ++ if (@available(iOS 26.0, *)) { ++ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; ++ } ++ navitem.leftBarButtonItems = nil; ++ navitem.rightBarButtonItems = nil; ++ } else { ++ navitem.leftBarButtonItems = leftBarButtonItems; ++ navitem.rightBarButtonItems = rightBarButtonItems; + } -+ navitem.leftBarButtonItems = nil; -+ navitem.rightBarButtonItems = nil; -+ } else { ++#else + navitem.leftBarButtonItems = leftBarButtonItems; + navitem.rightBarButtonItems = rightBarButtonItems; -+ } -+#else -+ navitem.leftBarButtonItems = leftBarButtonItems; -+ navitem.rightBarButtonItems = rightBarButtonItems; +#endif ++ // Only dict-driven items can be reused: with subview-backed items in the ++ // mix the applied state depends on view identity, so clear the key to ++ // force a rebuild on the next pass. ++ objc_setAssociatedObject( ++ navitem, ++ &RNSAppliedHeaderBarButtonConfigsKey, ++ subviewLeftItems.count == 0 && subviewRightItems.count == 0 ? headerItemsKey : nil, ++ OBJC_ASSOCIATION_RETAIN_NONATOMIC); ++ } + NSDictionary *mailSearchToolbarConfig = nil; + for (NSDictionary *toolbarConfig in config.headerToolbarItems) { + if (toolbarConfig[@"mailSearchToolbar"]) { @@ -491,20 +529,31 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + navigationToolbarConfigs = @[]; + } + -+ NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs -+ withCurrentItems:@[] -+ navigationItem:navitem]; -+ if (toolbarItems.count > 0) { -+ vc.toolbarItems = toolbarItems; -+ [navctr setToolbarHidden:NO animated:animated]; -+ } else { -+ vc.toolbarItems = nil; -+ [navctr setToolbarHidden:YES animated:animated]; ++ NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[]; ++ // Same reuse rule as the header item groups above. The top-view-controller ++ // and hidden-state checks scope the skip to same-screen refreshes, so ++ // transitions between screens with different toolbars still reapply. ++ BOOL reuseToolbarItems = navctr.topViewController == vc && ++ navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) && ++ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarConfigsKey]; ++ if (!reuseToolbarItems) { ++ NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs ++ withCurrentItems:@[] ++ navigationItem:navitem]; ++ if (toolbarItems.count > 0) { ++ vc.toolbarItems = toolbarItems; ++ [navctr setToolbarHidden:NO animated:animated]; ++ } else { ++ vc.toolbarItems = nil; ++ [navctr setToolbarHidden:YES animated:animated]; ++ } ++ objc_setAssociatedObject( ++ navitem, &RNSAppliedToolbarConfigsKey, toolbarConfigsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). -@@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -773,6 +1123,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems @@ -512,7 +561,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d { if (dicts.count == 0) { return currentItems; -@@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -781,7 +1132,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObjectsFromArray:currentItems]; for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; @@ -711,7 +760,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( -@@ -809,11 +1306,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -809,11 +1350,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObject:item]; } } else if (dict[@"spacing"]) { @@ -731,7 +780,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d NSNumber *index = dict[@"index"]; if (index.integerValue < items.count) { [items insertObject:item atIndex:index.integerValue]; -@@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -825,6 +1370,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } @@ -779,7 +828,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d RNS_IGNORE_SUPER_CALL_BEGIN - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex { -@@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1013,6 +1599,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); @@ -788,7 +837,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); } -@@ -1038,6 +1582,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1038,6 +1626,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; @@ -796,7 +845,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; -@@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1084,6 +1673,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b999993183..b0f06a0da3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,7 +82,7 @@ patchedDependencies: react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 + react-native-screens@4.25.2: b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669 importers: @@ -233,7 +233,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(e5d668c41c2566f67c53ad0aac3cb739) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -398,7 +398,7 @@ importers: version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -12253,7 +12253,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) + expo-router: 56.2.11(b039436655fcee7f5003056dafa9e030) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12329,7 +12329,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) + expo-router: 56.2.11(06a9ecbdc8060e84c6071d44cf7351da) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12669,7 +12669,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) + expo-router: 56.2.11(b039436655fcee7f5003056dafa9e030) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12684,7 +12684,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) + expo-router: 56.2.11(06a9ecbdc8060e84c6071d44cf7351da) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -14269,7 +14269,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(e5d668c41c2566f67c53ad0aac3cb739)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14277,7 +14277,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -17056,47 +17056,47 @@ snapshots: - supports-color - typescript - expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7): + expo-router@56.2.11(06a9ecbdc8060e84c6071d44cf7351da): dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@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) + '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@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.6)(utf-8-validate@6.0.6))(react@19.2.6) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(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-glass-effect: 56.0.4(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-linking: 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: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo-constants: 56.0.18(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)) + expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-linking: 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.6)(utf-8-validate@6.0.6))(react@19.2.6) expo-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(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-symbols: 56.0.6(expo-font@56.0.7)(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) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 query-string: 7.1.3 - react: 19.2.3 + react: 19.2.6 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-screens: 4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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.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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-dom: 19.2.6(react@19.2.6) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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))(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) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -17107,47 +17107,47 @@ snapshots: - supports-color optional: true - expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f): + expo-router@56.2.11(b039436655fcee7f5003056dafa9e030): dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@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.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@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) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - expo-constants: 56.0.18(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)) - expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-linking: 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.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo-constants: 56.0.18(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-glass-effect: 56.0.4(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-linking: 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-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-symbols: 56.0.6(expo-font@56.0.7)(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) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 query-string: 7.1.3 - react: 19.2.6 + react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-dom: 19.2.3(react@19.2.3) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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.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)(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) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -19953,14 +19953,14 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-screens@4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: react: 19.2.6 react-freeze: 1.0.4(react@19.2.6) From 75748bcbb8364504f493201f1ba57f0c14ffb49a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 19:35:54 +0200 Subject: [PATCH 13/46] fix(mobile): stabilize settings sheet navigation and updates - Prevent iOS header transitions from shifting the floating composer - Handle Clerk auth back navigation through the enclosing native route - Hide unavailable Expo update checks in development builds --- apps/mobile/src/Stack.tsx | 33 +++++--- .../archive/ArchivedThreadsRouteScreen.tsx | 5 +- .../cloud/ClerkSettingsSheetDetent.tsx | 44 ----------- .../cloud/connectOnboardingNavigation.ts | 4 +- .../src/features/home/HomeRouteScreen.tsx | 29 +++++-- .../layout/AdaptiveWorkspaceLayout.tsx | 10 ++- .../settings/SettingsAuthRouteScreen.tsx | 33 ++++---- .../SettingsEnvironmentsRouteScreen.tsx | 10 ++- .../features/settings/SettingsRouteScreen.tsx | 19 ++--- .../settings/components/SettingsRow.tsx | 3 +- .../showcase/ShowcaseCaptureCoordinator.tsx | 28 +++++-- .../src/features/updates/app-updates.test.ts | 39 +++++++++ .../src/features/updates/app-updates.ts | 23 +++++- patches/@clerk__expo@4.2.0.patch | 79 +++++++++++++++++++ pnpm-lock.yaml | 5 +- pnpm-workspace.yaml | 1 + 16 files changed, 249 insertions(+), 116 deletions(-) delete mode 100644 apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx create mode 100644 patches/@clerk__expo@4.2.0.patch diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index da1be88a8bd..93bb6165524 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; -import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; @@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { presentation: "fullScreenModal", }; -const SettingsSheetStack = createNativeStackNavigator({ +const SettingsContentStack = createNativeStackNavigator({ initialRouteName: "Settings", screenOptions: { ...GLASS_HEADER_OPTIONS, @@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Usage", }, }), + }, +}); + +// The outer stack never owns visible chrome. Settings routes render inside a +// nested stack whose native header remains mounted, while Clerk owns auth chrome. +// Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. +const SettingsSheetStack = createNativeStackNavigator({ + initialRouteName: "SettingsContent", + screenOptions: { + headerShown: false, + }, + screens: { + SettingsContent: createNativeStackScreen({ + screen: SettingsContentStack, + linking: "", + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", - options: { - title: "Sign in", - }, }), SettingsWaitlist: createNativeStackScreen({ // Keep the old deep link working after the Connect GA launch. screen: SettingsAuthRouteScreen, linking: "waitlist", - options: { - title: "Sign in", - }, }), }, }); @@ -347,11 +356,9 @@ function RootStackLayout(props: { - - - {props.children} - - + + {props.children} + ); } diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx index c2381ef2580..9ad4790faab 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -5,7 +5,6 @@ import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useMemo, useState } from "react"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; import { useArchivedThreadListActions } from "../home/useThreadListActions"; import { ArchivedThreadsScreen, @@ -18,7 +17,6 @@ import { } from "./useArchivedThreadSnapshots"; export function ArchivedThreadsRouteScreen() { - const { expand } = useClerkSettingsSheetDetent(); const { savedConnectionsById } = useSavedRemoteConnections(); const [searchQuery, setSearchQuery] = useState(""); const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); @@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() { useFocusEffect( useCallback(() => { - expand(); refresh(); - }, [expand, refresh]), + }, [refresh]), ); return ( diff --git a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx deleted file mode 100644 index 8bd51b8518d..00000000000 --- a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useMemo, - useState, -} from "react"; - -interface ClerkSettingsSheetDetentValue { - collapse: () => void; - expand: () => void; - isExpanded: boolean; -} - -const ClerkSettingsSheetDetentContext = createContext(null); - -interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { - initiallyExpanded: boolean; -} - -export function ClerkSettingsSheetDetentProvider({ - children, - initiallyExpanded, -}: ClerkSettingsSheetDetentProviderProps) { - const [isExpanded, setIsExpanded] = useState(initiallyExpanded); - const collapse = useCallback(() => setIsExpanded(false), []); - const expand = useCallback(() => setIsExpanded(true), []); - const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); - - return ( - {children} - ); -} - -export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { - const value = useContext(ClerkSettingsSheetDetentContext); - if (!value) { - throw new Error( - "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", - ); - } - return value; -} diff --git a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts index f937453e525..5c75df80cd3 100644 --- a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts +++ b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts @@ -6,8 +6,8 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; -// Sign-in happens inside the Settings sheet; give its detent/session-state -// transitions a beat to settle before presenting another formSheet on top. +// Sign-in happens inside the Settings sheet; give its session-state transition +// a beat to settle before presenting another formSheet on top. const PRESENT_ONBOARDING_DELAY_MS = 600; /** diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7760920f7db..d67446a61b4 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -134,7 +134,10 @@ export function HomeRouteScreen() { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }), })} /> - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }) + } + onOpenSettings={() => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }) } - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} @@ -161,7 +172,10 @@ export function HomeRouteScreen() { catalogState={catalogState} environments={environments} onAddConnection={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} @@ -174,7 +188,12 @@ export function HomeRouteScreen() { onMovePinnedThread={movePinnedThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} + onOpenSettings={() => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }) + } onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index a93268d0da6..e00433de0ed 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent( ); const handleOpenSettings = useCallback(() => { - navigation.navigate("SettingsSheet", { screen: "Settings" }); + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }); }, [navigation]); // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = useCallback(() => { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }); }, [navigation]); const handleNewThreadInProject = useCallback( diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index e4efdf70c31..5bc10af0a40 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -1,8 +1,7 @@ import { useAuth } from "@clerk/expo"; import { AuthView, UserProfileView } from "@clerk/expo/native"; import { StackActions, useNavigation } from "@react-navigation/native"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useEffect } from "react"; +import { useCallback, useLayoutEffect } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig"; export function SettingsAuthRouteScreen() { const navigation = useNavigation(); - useEffect(() => { + useLayoutEffect(() => { if (!hasCloudPublicConfig()) { - navigation.dispatch(StackActions.replace("Settings")); + navigation.dispatch(StackActions.replace("SettingsContent")); } }, [navigation]); @@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() { function ConfiguredSettingsAuthRouteScreen() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const navigation = useNavigation(); - const handleHostBack = useCallback(() => navigation.goBack(), [navigation]); + const handleHostBack = useCallback( + () => navigation.dispatch(StackActions.popTo("SettingsContent")), + [navigation], + ); return ( - <> - - - {isLoaded ? ( - isSignedIn ? ( - - ) : ( - - ) - ) : null} - - + + {isLoaded ? ( + isSignedIn ? ( + + ) : ( + + ) + ) : null} + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 53bbe480646..aa30242ea72 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() { accessibilityLabel: "Add environment", icon: "plus", onPress: () => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }), + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }), }, ]} /> @@ -108,7 +111,10 @@ export function SettingsEnvironmentsRouteScreen() { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) } separateBackground tintColor={headerIconColor} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 90e5af199de..4fb4b1a97a5 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; -import * as Updates from "expo-updates"; import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; @@ -30,7 +29,6 @@ import { subscribeAgentAwarenessRegistrationStatus, } from "../agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; @@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, + isAppUpdateCheckAvailable, registerHiddenUpdateTap, runAppUpdateCheck, } from "../updates/app-updates"; @@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() { const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const { savedConnectionsById } = useSavedRemoteConnections(); @@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() { const openAccount = useCallback(() => { if (!isLoaded) return; - if (!isSignedIn) { - expandClerkSheet(); - navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - return; - } - expandClerkSheet(); navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - }, [expandClerkSheet, isLoaded, isSignedIn, navigation]); + }, [isLoaded, navigation]); return ( @@ -577,6 +569,7 @@ function AppSettingsSection() { const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; const variantLabel = variant === "production" ? "" : capitalize(variant); const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; + const updateCheckAvailable = isAppUpdateCheckAvailable(); const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; @@ -604,13 +597,13 @@ function AppSettingsSection() { }, []); const handleVersionPress = useCallback(() => { - if (!Updates.isEnabled || updateInFlight.current) return; + if (!updateCheckAvailable || updateInFlight.current) return; const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); hiddenUpdateTapCount.current = tap.nextCount; if (tap.shouldCheck) { void checkForUpdate(); } - }, [checkForUpdate]); + }, [checkForUpdate, updateCheckAvailable]); const statusLabel = updateState === "checking" @@ -646,7 +639,7 @@ function AppSettingsSection() { - {Updates.isEnabled ? ( + {updateCheckAvailable ? ( navigation.navigate("SettingsSheet", { - screen: target, + screen: "SettingsContent", + params: { screen: target }, }) } > diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index ffeca9671b7..424822c35eb 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { Keyboard, View } from "react-native"; -import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { + CommonActions, + type NavigationState, + type PartialState, + StackActions, + useNavigation, +} from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useConnectionController } from "../connection/useConnectionController"; @@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; +type ShowcaseResetRoute = PartialState["routes"][number]; + function sceneFromPathname(pathname: string): ShowcaseScene | null { const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { @@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) navigation.dispatch(StackActions.popToTop()); return; } - const routes: Array<{ - name: string; - params?: Record; - state?: { index: number; routes: Array<{ name: string }> }; - }> = [{ name: "Home" }]; + const routes: ShowcaseResetRoute[] = [{ name: "Home" }]; if (requestedScene === "environments") { routes.push({ name: "SettingsSheet", state: { - index: 1, - routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + index: 0, + routes: [ + { + name: "SettingsContent", + state: { + index: 1, + routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + }, + }, + ], }, }); } else { diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index 474c99668cd..4926ae65ca3 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -32,6 +32,19 @@ function makeUpdateClient(overrides: Partial = {}): AppUpdateCl } describe("runAppUpdateCheck", () => { + it("does nothing while running from the Metro development server", async () => { + vi.stubGlobal("__DEV__", true); + const client = makeUpdateClient(); + + try { + await runAppUpdateCheck({ client }); + } finally { + vi.unstubAllGlobals(); + } + + expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); + }); + it("downloads and restarts when a new update is available", async () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => ({ @@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => { reportError.mockRestore(); }); + it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])( + "treats Expo's %s failure as an unavailable update check", + async (code) => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const error = Object.assign(new Error("Updates are unavailable"), { code }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => { + throw error; + }), + }); + const failures: string[] = []; + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ + client, + onFailure: (message) => failures.push(message), + onStateChange: (state) => states.push(state), + }); + + expect(reportError).not.toHaveBeenCalled(); + expect(failures).toEqual([]); + expect(states).toEqual(["checking", "idle"]); + reportError.mockRestore(); + }, + ); + it("coalesces overlapping launch and manual checks", async () => { let resolveCheck!: (result: { readonly isAvailable: boolean; diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index ab896b53c07..66525d02292 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -48,8 +48,17 @@ interface Deferred { } const HIDDEN_UPDATE_TAP_COUNT = 5; +const UPDATE_CHECK_UNAVAILABLE_ERROR_CODES = new Set([ + "ERR_NOT_AVAILABLE_IN_DEV_CLIENT", + "ERR_UPDATES_DISABLED", +]); let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; +/** Expo's development launcher reports updates as enabled even though its OTA APIs reject. */ +export function isAppUpdateCheckAvailable(client: Pick = Updates) { + return client.isEnabled && !(typeof __DEV__ !== "undefined" && __DEV__); +} + /** * Keeps the manual update affordance discoverable only to someone deliberately * tapping the version row five times. @@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): { export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise { const client = options.client ?? Updates; - if (!client.isEnabled) return; + if (!isAppUpdateCheckAvailable(client)) return; if (appUpdateCheckInFlight) { await observeAppUpdateCheck(appUpdateCheckInFlight, options); @@ -207,19 +216,27 @@ function reportUpdateFailure( fallback: string, onFailure: AppUpdateCheckOptions["onFailure"], ): void { - reportAtomCommandResult(result, { label: "app update check" }); if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); + if (isAppUpdateUnavailableError(error)) return; + + reportAtomCommandResult(result, { label: "app update check" }); onFailure?.(error instanceof Error ? error.message : fallback); } +function isAppUpdateUnavailableError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) return false; + const code = error.code; + return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code); +} + export function createAppUpdateLaunchCheck( client: AppUpdateClient = Updates, ): () => Promise | undefined { let started = false; return () => { - if (started || !client.isEnabled) return undefined; + if (started || !isAppUpdateCheckAvailable(client)) return undefined; started = true; return runAppUpdateCheck({ client }); }; diff --git a/patches/@clerk__expo@4.2.0.patch b/patches/@clerk__expo@4.2.0.patch new file mode 100644 index 00000000000..2d4a9287c11 --- /dev/null +++ b/patches/@clerk__expo@4.2.0.patch @@ -0,0 +1,79 @@ +diff --git a/ios/ClerkAuthNativeView.swift b/ios/ClerkAuthNativeView.swift +index e76a8be1b1c8faa64ec6dfa83764764094133aff..17b36ad1319765e2b6db0551d32e07d7140e482f 100644 +--- a/ios/ClerkAuthNativeView.swift ++++ b/ios/ClerkAuthNativeView.swift +@@ -108,7 +108,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { + + override func makeHostedController() -> UIViewController? { + let hostBackAction: (() -> Void)? = currentHostBackButton +- ? { [weak self] in self?.onHostBack([:]) } ++ ? { [weak self] in ++ guard let self else { return } ++ if !self.popEnclosingNavigationRoute() { ++ self.onHostBack([:]) ++ } ++ } + : nil + + return ClerkNativeBridge.shared.makeAuthViewController( +diff --git a/ios/ClerkNativeViewHost.swift b/ios/ClerkNativeViewHost.swift +index 0d91f0e749f121595c17bc803663df6ac90e4163..8f8a89df97a54168b8b45d0e9c197ca3953b7028 100644 +--- a/ios/ClerkNativeViewHost.swift ++++ b/ios/ClerkNativeViewHost.swift +@@ -5,6 +5,7 @@ public class ClerkNativeViewHost: ExpoView { + private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) + private var hasInitialized: Bool = false + private var configuredObserver: NSObjectProtocol? ++ private var isPoppingHostRoute = false + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) +@@ -58,6 +59,30 @@ public class ClerkNativeViewHost: ExpoView { + + func hostedViewDidDetachFromWindow() {} + ++ /// Pops the React Navigation route that contains this view without waiting for ++ /// the JavaScript event loop. React Native Screens reports the native dismissal ++ /// back to React Navigation so its state remains synchronized. ++ func popEnclosingNavigationRoute() -> Bool { ++ guard !isPoppingHostRoute else { return true } ++ ++ var responder: UIResponder? = self ++ ++ while let nextResponder = responder?.next { ++ if let viewController = nextResponder as? UIViewController, ++ let navigationController = viewController.navigationController, ++ navigationController.viewControllers.count > 1 { ++ isPoppingHostRoute = true ++ if navigationController.popViewController(animated: true) != nil { ++ return true ++ } ++ isPoppingHostRoute = false ++ } ++ responder = nextResponder ++ } ++ ++ return false ++ } ++ + private func addConfiguredObserver() { + guard configuredObserver == nil else { return } + +diff --git a/ios/ClerkUserProfileNativeView.swift b/ios/ClerkUserProfileNativeView.swift +index 12d6248b1dc4b4779b252c9954c2f89145907310..d838283b7adcf49fec2a63bff53251b9bee31bf8 100644 +--- a/ios/ClerkUserProfileNativeView.swift ++++ b/ios/ClerkUserProfileNativeView.swift +@@ -44,7 +44,12 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { + + override func makeHostedController() -> UIViewController? { + let hostBackAction: (() -> Void)? = currentHostBackButton +- ? { [weak self] in self?.onHostBack([:]) } ++ ? { [weak self] in ++ guard let self else { return } ++ if !self.popEnclosingNavigationRoute() { ++ self.onHostBack([:]) ++ } ++ } + : nil + + return ClerkNativeBridge.shared.makeUserProfileViewController( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0f06a0da3f..f547e63ba01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,7 @@ overrides: packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: + '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 @@ -197,7 +198,7 @@ importers: version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: 4.2.0 - version: 4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) @@ -11584,7 +11585,7 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': + '@clerk/expo@4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': dependencies: '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 27d86fd1784..3829850f51f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -122,6 +122,7 @@ packageExtensions: vite: "catalog:" patchedDependencies: + "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch From 2cf4d8c89a9dfded2bc9376037aedf7e7554fd82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 20:29:30 +0200 Subject: [PATCH 14/46] fix rns shit --- patches/react-native-screens@4.25.2.patch | 66 +++++++++++++++++------ pnpm-lock.yaml | 14 ++--- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch index a1c64eb0331..2bf3f3a8faf 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.25.2.patch @@ -140,7 +140,7 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm -index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce930d9fd7f 100644 +index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92d42ed9d9 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm @@ -25,11 +25,33 @@ @@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV -@@ -637,10 +675,322 @@ + (void)updateViewController:(UIViewController *)vc +@@ -637,10 +675,356 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -344,19 +344,17 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 + toolbarHost.tag = RNSMailSearchToolbarViewTag; + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; + [chromeHostView addSubview:toolbarHost]; -+ // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard -+ // layout guide no longer rests at the bottom safe-area edge while the -+ // keyboard is hidden, which pushed the toolbar offscreen. The required -+ // resting position is the safe area; the keyboard guide only pulls the -+ // toolbar up when it actually tracks a visible keyboard. ++ // The screen stays mounted beneath pushed routes, so its keyboard layout ++ // guide can track a keyboard owned by another screen. Keep the toolbar at ++ // rest unless its own search field is editing. + NSLayoutConstraint *keyboardAvoidConstraint = + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor + constant:-toolbarBottomSpacing]; -+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; ++ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; + NSLayoutConstraint *restingBottomConstraint = + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor + constant:-toolbarBottomSpacing]; -+ restingBottomConstraint.priority = UILayoutPriorityDefaultLow; ++ restingBottomConstraint.priority = UILayoutPriorityDefaultHigh; + [NSLayoutConstraint activateConstraints:@[ + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor @@ -368,6 +366,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 + ]]; + [chromeHostView bringSubviewToFront:toolbarHost]; + ++ void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) { ++ BOOL isEditing = textField.isFirstResponder; ++ keyboardAvoidConstraint.priority = ++ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; ++ restingBottomConstraint.priority = ++ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; ++ ++ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; ++ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; ++ __weak UIView *weakChromeHostView = chromeHostView; ++ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; ++ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; ++ [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin]; ++ [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd]; ++ [textField addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:beginActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow; ++ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; ++ [weakChromeHostView setNeedsLayout]; ++ }] ++ forControlEvents:UIControlEventEditingDidBegin]; ++ [textField addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:endActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; ++ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh; ++ [weakChromeHostView setNeedsLayout]; ++ }] ++ forControlEvents:UIControlEventEditingDidEnd]; ++ }; ++ + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; + glassEffect.interactive = YES; + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; @@ -449,6 +481,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; + searchBar.searchTextField.textColor = UIColor.labelColor; + searchBar.searchTextField.tintColor = UIColor.labelColor; ++ configureKeyboardTracking(searchBar.searchTextField); + if (placeholder != nil) { + searchBar.searchTextField.attributedPlaceholder = + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; @@ -481,6 +514,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 + searchField.adjustsFontForContentSizeCategory = YES; + searchField.textColor = UIColor.labelColor; + searchField.tintColor = UIColor.labelColor; ++ configureKeyboardTracking(searchField); + searchField.translatesAutoresizingMaskIntoConstraints = NO; + [glassView.contentView addSubview:searchField]; + [NSLayoutConstraint activateConstraints:@[ @@ -553,7 +587,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). -@@ -773,6 +1123,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -773,6 +1157,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems @@ -561,7 +595,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 { if (dicts.count == 0) { return currentItems; -@@ -781,7 +1132,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -781,7 +1166,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObjectsFromArray:currentItems]; for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; @@ -760,7 +794,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( -@@ -809,11 +1350,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -809,11 +1384,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObject:item]; } } else if (dict[@"spacing"]) { @@ -780,7 +814,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 NSNumber *index = dict[@"index"]; if (index.integerValue < items.count) { [items insertObject:item atIndex:index.integerValue]; -@@ -825,6 +1370,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -825,6 +1404,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } @@ -828,7 +862,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 RNS_IGNORE_SUPER_CALL_BEGIN - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex { -@@ -1013,6 +1599,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1013,6 +1633,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); @@ -837,7 +871,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); } -@@ -1038,6 +1626,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1038,6 +1660,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; @@ -845,7 +879,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..c78fdee16af3c1a0e060f167eab05ce9 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; -@@ -1084,6 +1673,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1084,6 +1707,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f547e63ba01..96293cf1422 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,7 +83,7 @@ patchedDependencies: react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.25.2: b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669 + react-native-screens@4.25.2: 36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d importers: @@ -399,7 +399,7 @@ importers: version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14278,7 +14278,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -17088,7 +17088,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-screens: 4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-screens: 4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -17139,7 +17139,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -19954,14 +19954,14 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-screens@4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-screens@4.25.2(patch_hash=b32f68cb60bbdd7677d1bb3400668efba7df4c6c2f1a74956cb86e2b8c7d6669)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-screens@4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: react: 19.2.6 react-freeze: 1.0.4(react@19.2.6) From 71d5d340182fdb77a5d61e09975c29e1c0b2a5a7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 20:42:25 +0200 Subject: [PATCH 15/46] feat(mobile): native menu for thread settings on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer settings pill now opens a native UIMenu (model, provider options, runtime mode as checkmarked submenus with current-value subtitles) instead of the JS pseudo-sheet, so the everyday adjustments apply without dismissing and re-raising the keyboard. "All Settings…" and Android keep the existing sheet. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadComposer.tsx | 103 ++++++- .../features/threads/ThreadSettingsSheet.tsx | 28 +- .../threads/thread-settings-menu.test.ts | 266 ++++++++++++++++++ .../features/threads/thread-settings-menu.ts | 204 ++++++++++++++ 4 files changed, 563 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-settings-menu.test.ts create mode 100644 apps/mobile/src/features/threads/thread-settings-menu.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c846dca287a..12190b52d51 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -14,6 +14,7 @@ import { serializeComposerFileLink, type ComposerTrigger, } from "@t3tools/shared/composerTrigger"; +import * as Haptics from "expo-haptics"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -51,7 +52,7 @@ import { ComposerToolbarScroller, ComposerToolbarTrigger, } from "../../components/ComposerToolbarTrigger"; -import { ControlPill } from "../../components/ControlPill"; +import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -62,9 +63,13 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; -import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; +import { + applyProviderOptionSelection, + resolveProviderOptionDescriptors, +} from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { buildThreadSettingsMenu } from "./thread-settings-menu"; import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; @@ -274,6 +279,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer editorRef: inputRef, isEditorFocused: isFocused, }); + const openSettingsSheet = settingsSheetPresentation.open; const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; @@ -623,6 +629,65 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer interactionMode: currentInteractionMode, }); + // iOS gets a native menu on the trigger pill: the everyday adjustments + // apply without resigning the keyboard, while "All Settings…" (and the + // Android trigger) still route through the sheet, which must dismiss it. + const settingsMenu = useMemo( + () => + Platform.OS === "ios" + ? buildThreadSettingsMenu({ + providerGroups: threadProviderGroups, + selectedModel: currentModelSelection, + optionDescriptors: providerOptionDescriptors, + runtimeMode: currentRuntimeMode, + }) + : null, + [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode], + ); + + const onUpdateModelSelection = props.onUpdateModelSelection; + const onUpdateRuntimeMode = props.onUpdateRuntimeMode; + const handleSettingsMenuAction = useCallback( + (eventId: string) => { + const event = settingsMenu?.events.get(eventId); + if (!event) { + return; + } + switch (event.type) { + case "select-model": + void Haptics.selectionAsync(); + onUpdateModelSelection(event.option.selection); + return; + case "set-option": { + const options = applyProviderOptionSelection(providerOptionDescriptors, { + id: event.optionId, + value: event.value, + }); + if (options) { + void Haptics.selectionAsync(); + onUpdateModelSelection({ ...currentModelSelection, options }); + } + return; + } + case "set-runtime": + void Haptics.selectionAsync(); + onUpdateRuntimeMode(event.mode); + return; + case "open-settings": + openSettingsSheet(); + return; + } + }, + [ + currentModelSelection, + onUpdateModelSelection, + onUpdateRuntimeMode, + openSettingsSheet, + providerOptionDescriptors, + settingsMenu, + ], + ); + return ( void props.onPickDraftImages()} showChevron={false} /> - - } - label={settingsSummaryLabel} - maxWidth={320} - onPress={settingsSheetPresentation.open} - /> + {settingsMenu ? ( + handleSettingsMenuAction(nativeEvent.event)} + > + + } + label={settingsSummaryLabel} + maxWidth={320} + /> + + ) : ( + + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> + )} {showStopAction ? ( = new Set(["claudeAgent", "codex"]); -/** - * Desktop-oriented effort keywords that don't belong in the phone picker. - * Prompt-injected values (ultrathink and friends) are filtered from the - * descriptor metadata; ultracode is a real option but a workflow trigger, not - * a reasoning level. A value set elsewhere still displays, it just isn't - * offered. - */ -const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); - -const RUNTIME_MODE_CHOICES: ReadonlyArray<{ - readonly mode: RuntimeMode; - readonly label: string; - readonly shortLabel: string; -}> = [ - { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, - { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, - { mode: "auto", label: "Auto", shortLabel: "Auto" }, - { mode: "full-access", label: "Full access", shortLabel: "Full" }, -]; - /** * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, * covering model, provider options, runtime mode, and plan mode in one label. @@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: { ].join(" · "); } -function selectableChoices(descriptor: Extract) { - const injected = new Set(descriptor.promptInjectedValues ?? []); - return descriptor.options.filter( - (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), - ); -} - function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts new file mode 100644 index 00000000000..ba98147a7ce --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-menu.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; + +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; +import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; + +function modelOption( + model: string, + overrides: Partial> = {}, +): ModelOption { + const providerKey = overrides.providerKey ?? "codex"; + return { + key: `${providerKey}:${model}`, + label: model, + subtitle: providerKey, + providerKey, + providerLabel: providerKey === "codex" ? "Codex" : "Claude", + providerDriver: providerKey === "codex" ? "codex" : "claudeAgent", + isDefault: overrides.isDefault ?? false, + isLegacy: overrides.isLegacy ?? false, + capabilities: null, + selection: { + instanceId: ProviderInstanceId.make(providerKey), + model, + options: [], + }, + }; +} + +function group(models: ReadonlyArray): ProviderGroup { + const first = models[0]; + if (!first) { + throw new Error("group requires at least one model"); + } + return { + providerKey: first.providerKey, + providerLabel: first.providerLabel, + models, + }; +} + +const effortDescriptor: ProviderOptionDescriptor = { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + { id: "ultracode", label: "Ultracode" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], +}; + +const fastModeDescriptor: ProviderOptionDescriptor = { + id: "fastMode", + label: "Fast mode", + type: "boolean", + currentValue: false, +}; + +function baseInput() { + const models = [ + modelOption("gpt-current", { isDefault: true }), + modelOption("gpt-next"), + modelOption("gpt-old", { isLegacy: true }), + ]; + return { + providerGroups: [group(models)], + selectedModel: models[0]?.selection ?? null, + optionDescriptors: [effortDescriptor, fastModeDescriptor], + runtimeMode: "auto", + } as const; +} + +function eventFor(menu: ReturnType, id: string | undefined) { + return id === undefined ? undefined : menu.events.get(id); +} + +describe("buildThreadSettingsMenu", () => { + it("orders the top level as model, options, runtime, settings escape hatch", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + expect(menu.actions.map((action) => action.title)).toEqual([ + "Model", + "Reasoning", + "Fast mode", + "Runtime", + "", + ]); + const settingsSection = menu.actions.at(-1); + expect(settingsSection?.displayInline).toBe(true); + expect(settingsSection?.subactions?.map((action) => action.title)).toEqual(["All Settings…"]); + expect(eventFor(menu, settingsSection?.subactions?.[0]?.id)).toEqual({ + type: "open-settings", + }); + }); + + it("summarizes the current choice on each submenu row", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current"); + expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High"); + expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto"); + }); + + it("checkmarks the selected model and resolves selection events", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + const current = modelItems.find((action) => action.title === "gpt-current"); + expect(current?.state).toBe("on"); + expect(current?.subtitle).toBe("Default"); + expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off"); + + const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id); + expect(event?.type).toBe("select-model"); + expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next"); + }); + + it("folds unselected legacy models behind a nested submenu", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect(modelItems.map((action) => action.title)).toEqual([ + "gpt-current", + "gpt-next", + "Legacy Models", + ]); + expect( + modelItems + .find((action) => action.title === "Legacy Models") + ?.subactions?.map((action) => action.title), + ).toEqual(["gpt-old"]); + }); + + it("keeps a selected legacy model in the main list", () => { + const input = baseInput(); + const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy); + const menu = buildThreadSettingsMenu({ + ...input, + selectedModel: legacy?.selection ?? null, + }); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect(modelItems.map((action) => action.title)).toEqual([ + "gpt-current", + "gpt-next", + "gpt-old", + ]); + expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on"); + }); + + it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => { + const menu = buildThreadSettingsMenu({ + ...baseInput(), + optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }], + }); + + const reasoning = menu.actions.find((action) => action.title === "Reasoning"); + expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]); + // The hidden value stays visible as the current summary; it just can't be + // picked from the phone. + expect(reasoning?.subtitle).toBe("Ultracode"); + expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true); + }); + + it("resolves select-option and runtime events with checkmarked current values", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const reasoningItems = + menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; + expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on"); + expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({ + type: "set-option", + optionId: "effort", + value: "low", + }); + + const runtimeItems = + menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; + expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on"); + expect( + eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id), + ).toEqual({ type: "set-runtime", mode: "full-access" }); + }); + + it("toggles boolean options with the inverted current value", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const fastMode = menu.actions.find((action) => action.title === "Fast mode"); + expect(fastMode?.state).toBe("off"); + expect(fastMode?.subactions).toBeUndefined(); + expect(eventFor(menu, fastMode?.id)).toEqual({ + type: "set-option", + optionId: "fastMode", + value: true, + }); + + const enabled = buildThreadSettingsMenu({ + ...baseInput(), + optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }], + }); + const enabledRow = enabled.actions.find((action) => action.title === "Fast mode"); + expect(enabledRow?.state).toBe("on"); + expect(eventFor(enabled, enabledRow?.id)).toEqual({ + type: "set-option", + optionId: "fastMode", + value: false, + }); + }); + + it("sections models by provider only when multiple groups are offered", () => { + const codexModels = [modelOption("gpt-current", { isDefault: true })]; + const claudeModels = [modelOption("fable-5", { providerKey: "claude" })]; + const menu = buildThreadSettingsMenu({ + providerGroups: [group(codexModels), group(claudeModels)], + selectedModel: codexModels[0]?.selection ?? null, + optionDescriptors: [], + runtimeMode: "auto", + }); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect( + modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })), + ).toEqual([ + { title: "Codex", inline: true }, + { title: "Claude", inline: true }, + ]); + const claudeSection = modelItems.find((action) => action.title === "Claude"); + expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]); + }); + + const eventTypes = (menu: ReturnType) => { + const types = new Set(); + for (const event of menu.events.values()) { + types.add(event.type); + } + return types; + }; + + it("registers an event for every leaf action id", () => { + const menu = buildThreadSettingsMenu(baseInput()); + const leafIds: string[] = []; + const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => { + for (const item of items) { + if (Array.isArray(item.subactions) && item.subactions.length > 0) { + collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>); + } else if (item.id !== undefined) { + leafIds.push(item.id); + } + } + }; + collect(menu.actions); + + for (const id of leafIds) { + expect(menu.events.get(id), `missing event for ${id}`).toBeDefined(); + } + expect(eventTypes(menu)).toEqual( + new Set(["select-model", "set-option", "set-runtime", "open-settings"]), + ); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts new file mode 100644 index 00000000000..6c400bb29f7 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-menu.ts @@ -0,0 +1,204 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; +import { + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, +} from "@t3tools/shared/model"; + +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; + +/** + * Desktop-oriented effort keywords that don't belong in the phone picker. + * Prompt-injected values (ultrathink and friends) are filtered from the + * descriptor metadata; ultracode is a real option but a workflow trigger, not + * a reasoning level. A value set elsewhere still displays, it just isn't + * offered. + */ +export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); + +export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ + readonly mode: RuntimeMode; + readonly label: string; + readonly shortLabel: string; +}> = [ + { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, + { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, + { mode: "auto", label: "Auto", shortLabel: "Auto" }, + { mode: "full-access", label: "Full access", shortLabel: "Full" }, +]; + +export function selectableChoices( + descriptor: Extract, +) { + const injected = new Set(descriptor.promptInjectedValues ?? []); + return descriptor.options.filter( + (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), + ); +} + +export type ThreadSettingsMenuEvent = + | { readonly type: "select-model"; readonly option: ModelOption } + | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } + | { readonly type: "set-runtime"; readonly mode: RuntimeMode } + | { readonly type: "open-settings" }; + +export type ThreadSettingsMenu = { + readonly actions: MenuAction[]; + /** Menu action id → the change it applies, for the onPressAction dispatch. */ + readonly events: ReadonlyMap; +}; + +/** + * Native menu equivalent of the thread settings sheet for the everyday + * adjustments (model, select/boolean provider options, runtime mode). The + * menu presents from the composer pill without resigning the keyboard, so the + * common flow never bounces focus; "All Settings…" falls back to the sheet + * for anything richer. + * + * Selections apply immediately — the sheet's stage-then-Save flow only exists + * because the sheet batches a model change with its option edits, and a menu + * closes after each pick anyway. + */ +export function buildThreadSettingsMenu(input: { + readonly providerGroups: ReadonlyArray; + readonly selectedModel: ModelSelection | null; + readonly optionDescriptors: ReadonlyArray; + readonly runtimeMode: RuntimeMode; +}): ThreadSettingsMenu { + const events = new Map(); + const actions: MenuAction[] = []; + + const isSelected = (option: ModelOption) => + option.selection.instanceId === input.selectedModel?.instanceId && + option.selection.model === input.selectedModel.model; + + const modelAction = (option: ModelOption, id: string): MenuAction => { + events.set(id, { type: "select-model", option }); + return { + id, + title: option.label, + ...(option.isDefault ? { subtitle: "Default" } : {}), + state: isSelected(option) ? "on" : "off", + }; + }; + + const modelItems: MenuAction[] = []; + const legacyItems: MenuAction[] = []; + let selectedModelLabel: string | undefined; + input.providerGroups.forEach((group, groupIndex) => { + const groupItems: MenuAction[] = []; + group.models.forEach((option, modelIndex) => { + if (isSelected(option)) { + selectedModelLabel = option.label; + } + const id = `model:${groupIndex}:${modelIndex}`; + // A highlighted legacy model stays in the main list (mirroring the + // sheet) so the checkmark isn't hidden behind the Legacy fold. + if (option.isLegacy && !isSelected(option)) { + legacyItems.push(modelAction(option, id)); + } else { + groupItems.push(modelAction(option, id)); + } + }); + if (groupItems.length === 0) { + return; + } + // A thread is bound to one harness, so provider sections only appear for + // multi-group callers (the new-task draft, if it ever adopts the menu). + if (input.providerGroups.length > 1) { + modelItems.push({ + id: `model-group:${groupIndex}`, + title: group.providerLabel, + displayInline: true, + subactions: groupItems, + }); + } else { + modelItems.push(...groupItems); + } + }); + if (legacyItems.length > 0) { + modelItems.push({ + id: "legacy-models", + title: "Legacy Models", + subactions: legacyItems, + }); + } + if (modelItems.length > 0) { + actions.push({ + id: "model", + title: "Model", + ...(selectedModelLabel === undefined + ? input.selectedModel + ? { subtitle: input.selectedModel.model } + : {} + : { subtitle: selectedModelLabel }), + subactions: modelItems, + }); + } + + for (const descriptor of input.optionDescriptors) { + if (descriptor.type === "boolean") { + const id = `option:${descriptor.id}`; + events.set(id, { + type: "set-option", + optionId: descriptor.id, + value: !(descriptor.currentValue ?? false), + }); + actions.push({ + id, + title: descriptor.label, + state: descriptor.currentValue ? "on" : "off", + }); + continue; + } + const currentValue = getProviderOptionCurrentValue(descriptor); + const choices = selectableChoices(descriptor).map((choice): MenuAction => { + const id = `option:${descriptor.id}:${choice.id}`; + events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id }); + return { + id, + title: choice.label, + state: choice.id === currentValue ? "on" : "off", + }; + }); + if (choices.length === 0) { + continue; + } + const currentLabel = getProviderOptionCurrentLabel(descriptor); + actions.push({ + id: `option:${descriptor.id}`, + title: descriptor.label, + ...(currentLabel === undefined ? {} : { subtitle: currentLabel }), + subactions: choices, + }); + } + + const runtimeLabel = RUNTIME_MODE_CHOICES.find( + (choice) => choice.mode === input.runtimeMode, + )?.label; + actions.push({ + id: "runtime", + title: "Runtime", + ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }), + subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => { + const id = `runtime:${choice.mode}`; + events.set(id, { type: "set-runtime", mode: choice.mode }); + return { + id, + title: choice.label, + state: choice.mode === input.runtimeMode ? "on" : "off", + }; + }), + }); + + events.set("open-settings", { type: "open-settings" }); + actions.push({ + // Inline single-item section renders the system divider above the row. + id: "settings-section", + title: "", + displayInline: true, + subactions: [{ id: "open-settings", title: "All Settings…", image: "slider.horizontal.3" }], + }); + + return { actions, events }; +} From 834d994bf5a98c30847d2e25c5e0aef8b1e1df96 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 20:59:00 +0200 Subject: [PATCH 16/46] fix(mobile): submenu subtitles and multi-pick settings menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu library never assigned UIMenu.subtitle, so submenu rows (Model, Reasoning, Runtime) lost their current-value summaries — set it alongside a stable UIMenu identifier. Leaf picks now use keepsMenuPresented so several dimensions can be adjusted in one visit, with the native view pushing rebuilt actions into the visible menu via updateVisibleMenu so checkmarks and subtitles refresh in place; identifiers keep the user's submenu level across that refresh. All Settings… still closes the menu. Co-Authored-By: Claude Fable 5 --- .../threads/thread-settings-menu.test.ts | 24 +++++++++ .../features/threads/thread-settings-menu.ts | 10 ++++ patches/@react-native-menu__menu@2.0.0.patch | 52 ++++++++++++++++++- pnpm-lock.yaml | 22 ++++---- 4 files changed, 95 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts index ba98147a7ce..1c776057010 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.test.ts @@ -213,6 +213,30 @@ describe("buildThreadSettingsMenu", () => { }); }); + it("keeps the menu presented for picks but not for the settings hand-off", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect( + modelItems.find((action) => action.title === "gpt-next")?.attributes?.keepsMenuPresented, + ).toBe(true); + expect( + menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, + ).toBe(true); + const reasoningItems = + menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; + const runtimeItems = + menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; + expect( + [...reasoningItems, ...runtimeItems].every( + (action) => action.attributes?.keepsMenuPresented === true, + ), + ).toBe(true); + + // The sheet hand-off must dismiss the menu before presenting. + expect(menu.actions.at(-1)?.subactions?.[0]?.attributes).toBeUndefined(); + }); + it("sections models by provider only when multiple groups are offered", () => { const codexModels = [modelOption("gpt-current", { isDefault: true })]; const claudeModels = [modelOption("fable-5", { providerKey: "claude" })]; diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts index 6c400bb29f7..2f31ff10c5a 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.ts @@ -72,6 +72,12 @@ export function buildThreadSettingsMenu(input: { option.selection.instanceId === input.selectedModel?.instanceId && option.selection.model === input.selectedModel.model; + // Leaf picks keep the menu presented (iOS 16+) so several dimensions can be + // adjusted in one visit; the native side refreshes the visible menu when the + // rebuilt actions arrive, which restores the checkmarks. "All Settings…" is + // the exception — it hands off to the sheet, so the menu must close. + const keepPresented = { keepsMenuPresented: true } as const; + const modelAction = (option: ModelOption, id: string): MenuAction => { events.set(id, { type: "select-model", option }); return { @@ -79,6 +85,7 @@ export function buildThreadSettingsMenu(input: { title: option.label, ...(option.isDefault ? { subtitle: "Default" } : {}), state: isSelected(option) ? "on" : "off", + attributes: keepPresented, }; }; @@ -148,6 +155,7 @@ export function buildThreadSettingsMenu(input: { id, title: descriptor.label, state: descriptor.currentValue ? "on" : "off", + attributes: keepPresented, }); continue; } @@ -159,6 +167,7 @@ export function buildThreadSettingsMenu(input: { id, title: choice.label, state: choice.id === currentValue ? "on" : "off", + attributes: keepPresented, }; }); if (choices.length === 0) { @@ -187,6 +196,7 @@ export function buildThreadSettingsMenu(input: { id, title: choice.label, state: choice.mode === input.runtimeMode ? "on" : "off", + attributes: keepPresented, }; }), }); diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index f03ef60bb5b..8ce7c98058c 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -1,10 +1,17 @@ diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..41795a3e53a33ca0e7c608432737bee13c92be84 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift -@@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton { +@@ -87,7 +87,65 @@ public class MenuViewImplementation: UIButton { + } self.menu = menu ++ // An action fired with keepsMenuPresented leaves the menu on screen, ++ // but the presented copy is a snapshot: replacing self.menu alone ++ // never repaints it. Push the rebuilt tree into whichever interaction ++ // is showing it so checkmarks and subtitles track the new JS state ++ // (no-op while nothing is presented). ++ self.refreshPresentedMenu(menu) self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress + // In long-press mode the button must not intercept touches: as a + // full-bounds contentView it sits IN FRONT of the React children of @@ -17,6 +24,23 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 + self.updateLongPressInteraction() + } + ++ private func refreshPresentedMenu(_ menu: UIMenu) { ++ // Tap mode presents through the button's built-in interaction, ++ // long-press mode through the superview-hosted one; cover both (plus ++ // the interaction added in init) and dedupe by identity. ++ var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction } ++ if let builtin = self.contextMenuInteraction { ++ candidates.append(builtin) ++ } ++ if let host = longPressInteraction { ++ candidates.append(host) ++ } ++ var visited: Set = [] ++ for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted { ++ interaction.updateVisibleMenu { _ in menu } ++ } ++ } ++ + private var longPressInteraction: UIContextMenuInteraction? + + public override func didMoveToSuperview() { @@ -44,3 +68,27 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 } public override func reactSetFrame(_ frame: CGRect) { +diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift +index bb6bb2b7ad56135089f267587c974b166760539d..43e96f209480c3b076786cd1772f1291fa236dce 100644 +--- a/ios/Shared/RCTMenuItem.swift ++++ b/ios/Shared/RCTMenuItem.swift +@@ -103,10 +103,17 @@ class RCTMenuAction { + subMenuActions.append(subaction.createUIMenuElement(handler)) + } + var menu: UIMenu; ++ // Stable identifiers let updateVisibleMenu match submenu nodes in ++ // place, so a refresh while the menu is presented keeps the user's ++ // current submenu level instead of popping back to the root. ++ let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) } + if self.displayInline { +- menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions) ++ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, options: .displayInline, children: subMenuActions) + } else { +- menu = UIMenu(title: title, image: image, children: subMenuActions) ++ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions) ++ } ++ if #available(iOS 15.0, *) { ++ menu.subtitle = subtitle + } + + if #available(iOS 16.0, *) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96293cf1422..335cbc4882e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ patchedDependencies: '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa - '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae + '@react-native-menu/menu@2.0.0': c0159b33ee791e0d1964bf70552a09399996b1d0c37bbc0a7f1227f1df42350b '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 @@ -225,7 +225,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=c0159b33ee791e0d1964bf70552a09399996b1d0c37bbc0a7f1227f1df42350b)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) @@ -234,7 +234,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(e5d668c41c2566f67c53ad0aac3cb739) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(5be33aef4baeb633179867b7d83cc53c) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -12254,7 +12254,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(b039436655fcee7f5003056dafa9e030) + expo-router: 56.2.11(e85d238e900883f08c219be803dfa01b) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12330,7 +12330,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(06a9ecbdc8060e84c6071d44cf7351da) + expo-router: 56.2.11(7c3ddb554a712b24b8b9902623b16c5e) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12670,7 +12670,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(b039436655fcee7f5003056dafa9e030) + expo-router: 56.2.11(e85d238e900883f08c219be803dfa01b) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12685,7 +12685,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-router: 56.2.11(06a9ecbdc8060e84c6071d44cf7351da) + expo-router: 56.2.11(7c3ddb554a712b24b8b9902623b16c5e) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -14092,7 +14092,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=c0159b33ee791e0d1964bf70552a09399996b1d0c37bbc0a7f1227f1df42350b)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -14270,7 +14270,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(e5d668c41c2566f67c53ad0aac3cb739)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(5be33aef4baeb633179867b7d83cc53c)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -17057,7 +17057,7 @@ snapshots: - supports-color - typescript - expo-router@56.2.11(06a9ecbdc8060e84c6071d44cf7351da): + expo-router@56.2.11(7c3ddb554a712b24b8b9902623b16c5e): dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) @@ -17108,7 +17108,7 @@ snapshots: - supports-color optional: true - expo-router@56.2.11(b039436655fcee7f5003056dafa9e030): + expo-router@56.2.11(e85d238e900883f08c219be803dfa01b): dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) From 3b37c707ae15f25628df8664ee325568b0fdbea1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 21:17:08 +0200 Subject: [PATCH 17/46] fix(mobile): pass keepsMenuPresented through the menu codegen bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fabric codegen spec only declared destructive/disabled/hidden in the action attributes struct, so keepsMenuPresented was silently dropped at the JS→C++ boundary and menus always closed on selection. Declare it at every level, add a third nesting level (submenus inside submenus were being stripped the same way), forward both through convertActionsToObjC, and guard the native view against reassigning UIButton.menu while the menu is presented — that reassignment dismisses the presentation, which would have defeated the attribute the moment refreshed actions arrived. Deferred props land via setup() on dismissal. Co-Authored-By: Claude Fable 5 --- patches/@react-native-menu__menu@2.0.0.patch | 145 ++++++++++++++++++- pnpm-lock.yaml | 6 +- 2 files changed, 140 insertions(+), 11 deletions(-) diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index 8ce7c98058c..d05f1b4757e 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -1,17 +1,99 @@ +diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm +index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644 +--- a/ios/NewArch/MenuView.mm ++++ b/ios/NewArch/MenuView.mm +@@ -105,6 +105,27 @@ - (void)onOpenMenu { + NSMutableArray *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()]; + if (action.subactions.size() > 0) { + for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) { ++ NSMutableArray *subSubactionsArray = ++ [NSMutableArray arrayWithCapacity:subaction.subactions.size()]; ++ for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) { ++ NSDictionary *subSubactionDict = @{ ++ @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()], ++ @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()], ++ @"titleColor": @(subSubaction.titleColor), ++ @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()], ++ @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()], ++ @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()], ++ @"imageColor": @(subSubaction.imageColor), ++ @"displayInline": @(subSubaction.displayInline), ++ @"attributes": @{ ++ @"destructive": @(subSubaction.attributes.destructive), ++ @"disabled": @(subSubaction.attributes.disabled), ++ @"hidden": @(subSubaction.attributes.hidden), ++ @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented), ++ }, ++ }; ++ [subSubactionsArray addObject:subSubactionDict]; ++ } + NSDictionary *subactionDict = @{ + @"id": [NSString stringWithUTF8String:subaction.id.c_str()], + @"title": [NSString stringWithUTF8String:subaction.title.c_str()], +@@ -118,7 +139,9 @@ - (void)onOpenMenu { + @"destructive": @(subaction.attributes.destructive), + @"disabled": @(subaction.attributes.disabled), + @"hidden": @(subaction.attributes.hidden), ++ @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented), + }, ++ @"subactions": subSubactionsArray, + }; + [subactionsArray addObject:subactionDict]; + } +@@ -138,6 +161,7 @@ - (void)onOpenMenu { + @"destructive": @(action.attributes.destructive), + @"disabled": @(action.attributes.disabled), + @"hidden": @(action.attributes.hidden), ++ @"keepsMenuPresented": @(action.attributes.keepsMenuPresented), + }, + @"subactions": subactionsArray, + }; diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..41795a3e53a33ca0e7c608432737bee13c92be84 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..12af9a25879be16143bb1af6fd8b776938095d0d 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift -@@ -87,7 +87,65 @@ public class MenuViewImplementation: UIButton { +@@ -67,10 +67,25 @@ public class MenuViewImplementation: UIButton { + } + } + ++ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { ++ isMenuPresented = true ++ } ++ + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + sendMenuClose() ++ isMenuPresented = false ++ if pendingMenu != nil { ++ // Re-run the full assignment now that presentation is over, so ++ // any props deferred by the presented-guard (menu contents, press ++ // mode) land on the button. ++ pendingMenu = nil ++ self.setup() ++ } + } + ++ private var isMenuPresented = false ++ private var pendingMenu: UIMenu? ++ + func setup () { + let menu = UIMenu(title: _title, + identifier: nil, +@@ -86,8 +101,71 @@ public class MenuViewImplementation: UIButton { + } } ++ if isMenuPresented { ++ // An action fired with keepsMenuPresented leaves the menu on ++ // screen, and reassigning self.menu while it is presented makes ++ // UIKit dismiss it — the update triggered by a selection would ++ // defeat the keep-presented attribute. Update the visible copy in ++ // place instead and defer the button assignment to dismissal. ++ pendingMenu = menu ++ self.refreshPresentedMenu(menu) ++ return ++ } ++ self.menu = menu -+ // An action fired with keepsMenuPresented leaves the menu on screen, -+ // but the presented copy is a snapshot: replacing self.menu alone -+ // never repaints it. Push the rebuilt tree into whichever interaction -+ // is showing it so checkmarks and subtitles track the new JS state -+ // (no-op while nothing is presented). -+ self.refreshPresentedMenu(menu) self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress + // In long-press mode the button must not intercept touches: as a + // full-bounds contentView it sits IN FRONT of the React children of @@ -92,3 +174,50 @@ index bb6bb2b7ad56135089f267587c974b166760539d..43e96f209480c3b076786cd1772f1291 } if #available(iOS 16.0, *) { +diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts +index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644 +--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts ++++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts +@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati + types here, to avoid issues while `pod install` takes place. + */ + ++type SubSubAction = { ++ id?: string; ++ title: string; ++ titleColor?: Int32; ++ subtitle?: string; ++ state?: string; ++ image?: string; ++ imageColor?: Int32; ++ displayInline?: boolean; ++ attributes?: { ++ destructive?: boolean; ++ disabled?: boolean; ++ hidden?: boolean; ++ keepsMenuPresented?: boolean; ++ }; ++}; + type SubAction = { + id?: string; + title: string; +@@ -26,7 +42,11 @@ type SubAction = { + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; ++ keepsMenuPresented?: boolean; + }; ++ // One extra nesting level (menu → submenu → nested submenu leaves); the ++ // codegen structs can't recurse, so depth is capped explicitly. ++ subactions?: Array; + }; + type MenuAction = { + id?: string; +@@ -41,6 +61,7 @@ type MenuAction = { + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; ++ keepsMenuPresented?: boolean; + }; + subactions?: Array; + }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 335cbc4882e..97afdd2eca2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ patchedDependencies: '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa - '@react-native-menu/menu@2.0.0': c0159b33ee791e0d1964bf70552a09399996b1d0c37bbc0a7f1227f1df42350b + '@react-native-menu/menu@2.0.0': 806c1c99e10e5fb6e7c9604aff5b296b0f49fe7188fe62e7a4ec688acd8c387b '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 @@ -225,7 +225,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=c0159b33ee791e0d1964bf70552a09399996b1d0c37bbc0a7f1227f1df42350b)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=806c1c99e10e5fb6e7c9604aff5b296b0f49fe7188fe62e7a4ec688acd8c387b)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) @@ -14092,7 +14092,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=c0159b33ee791e0d1964bf70552a09399996b1d0c37bbc0a7f1227f1df42350b)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=806c1c99e10e5fb6e7c9604aff5b296b0f49fe7188fe62e7a4ec688acd8c387b)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From cfaf85d97f73804c493e699cf825eee5e961d765 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 21:58:09 +0200 Subject: [PATCH 18/46] fix(mobile): drop menu willDisplay override that broke button chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overriding contextMenuInteraction(_:willDisplayMenuFor:animator:) on the menu button — even just to flag presentation — shadows UIButton's own implementation and degrades the button-anchored presentation into generic context-menu chrome: an empty header row with a dismiss chevron rendered above the actions. Track presentation from the two delegate methods the class already overrode instead (configurationForMenuAtLocation / willEndFor), flushing any deferred menu there so a stuck flag can never serve a stale menu. Inline sections also skip the new stable identifier; they don't navigate, so they stay closest to stock rendering. Co-Authored-By: Claude Fable 5 --- patches/@react-native-menu__menu@2.0.0.patch | 41 ++++++++++++++------ pnpm-lock.yaml | 6 +-- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index d05f1b4757e..0e9342fcfa0 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -49,16 +49,35 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a @"subactions": subactionsArray, }; diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..12af9a25879be16143bb1af6fd8b776938095d0d 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..d1fc3efd581fe83efa2b5aba1857e298f57f866d 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift -@@ -67,10 +67,25 @@ public class MenuViewImplementation: UIButton { - } +@@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton { + self.setup() } - -+ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + ++ // Presentation is tracked from the two delegate methods the class already ++ // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping) ++ // shadows UIButton's own implementation and degrades the button-anchored ++ // presentation into generic context-menu chrome — an empty header row with ++ // a dismiss chevron appears above the actions. + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { ++ // Flush updates deferred by the presented-guard before the action ++ // provider snapshots self.menu (covers a stuck flag from an ++ // interaction that never ended cleanly). ++ if pendingMenu != nil { ++ pendingMenu = nil ++ isMenuPresented = false ++ self.setup() ++ } + isMenuPresented = true -+ } + sendMenuOpen() + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self = self else { return nil } + return self.menu + } + } +- + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { sendMenuClose() @@ -78,7 +97,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..12af9a25879be16143bb1af6fd8b7769 func setup () { let menu = UIMenu(title: _title, identifier: nil, -@@ -86,8 +101,71 @@ public class MenuViewImplementation: UIButton { +@@ -86,8 +111,71 @@ public class MenuViewImplementation: UIButton { } } @@ -151,20 +170,20 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..12af9a25879be16143bb1af6fd8b7769 public override func reactSetFrame(_ frame: CGRect) { diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift -index bb6bb2b7ad56135089f267587c974b166760539d..43e96f209480c3b076786cd1772f1291fa236dce 100644 +index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644 --- a/ios/Shared/RCTMenuItem.swift +++ b/ios/Shared/RCTMenuItem.swift -@@ -103,10 +103,17 @@ class RCTMenuAction { +@@ -103,10 +103,18 @@ class RCTMenuAction { subMenuActions.append(subaction.createUIMenuElement(handler)) } var menu: UIMenu; + // Stable identifiers let updateVisibleMenu match submenu nodes in + // place, so a refresh while the menu is presented keeps the user's + // current submenu level instead of popping back to the root. ++ // Inline sections don't navigate, so they stay stock. + let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) } if self.displayInline { -- menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions) -+ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, options: .displayInline, children: subMenuActions) + menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions) } else { - menu = UIMenu(title: title, image: image, children: subMenuActions) + menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97afdd2eca2..747f32c4e22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ patchedDependencies: '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa - '@react-native-menu/menu@2.0.0': 806c1c99e10e5fb6e7c9604aff5b296b0f49fe7188fe62e7a4ec688acd8c387b + '@react-native-menu/menu@2.0.0': 4396daa9a90dbd072ad9d0ebbb4a34eebe492d04b8b29422888fb5549d057776 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 @@ -225,7 +225,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=806c1c99e10e5fb6e7c9604aff5b296b0f49fe7188fe62e7a4ec688acd8c387b)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=4396daa9a90dbd072ad9d0ebbb4a34eebe492d04b8b29422888fb5549d057776)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) @@ -14092,7 +14092,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=806c1c99e10e5fb6e7c9604aff5b296b0f49fe7188fe62e7a4ec688acd8c387b)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=4396daa9a90dbd072ad9d0ebbb4a34eebe492d04b8b29422888fb5549d057776)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From 68a314eaba7ee838d09e181f18e0f2536fd3ac98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 22:28:41 +0200 Subject: [PATCH 19/46] fix(mobile): refresh the visible menu level instead of replacing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateVisibleMenu hands the block whichever menu level is currently on screen — the navigated submenu after a keepsMenuPresented pick, not the root. Returning the rebuilt root menu made UIKit render it as navigation into a foreign menu, leaving a blank (or stale-titled) expanded-submenu header row above the actions. Match the visible level against the rebuilt tree by the stable UIMenu identifiers and return that node so the level updates in place (checkmarks and header subtitle included); the root has an auto identifier and falls through to a children-only replacement. Verified on the iOS 26.5 simulator: fresh open, in-submenu pick with the menu staying presented, and collapse back to root all render clean. Co-Authored-By: Claude Fable 5 --- patches/@react-native-menu__menu@2.0.0.patch | 33 ++++++++++++++++++-- pnpm-lock.yaml | 6 ++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index 0e9342fcfa0..8794cf208ee 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -49,7 +49,7 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a @"subactions": subactionsArray, }; diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..d1fc3efd581fe83efa2b5aba1857e298f57f866d 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift @@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton { @@ -97,7 +97,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..d1fc3efd581fe83efa2b5aba1857e298 func setup () { let menu = UIMenu(title: _title, identifier: nil, -@@ -86,8 +111,71 @@ public class MenuViewImplementation: UIButton { +@@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton { } } @@ -138,8 +138,35 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..d1fc3efd581fe83efa2b5aba1857e298 + } + var visited: Set = [] + for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted { -+ interaction.updateVisibleMenu { _ in menu } ++ // The block receives whichever menu level is currently on screen — ++ // the navigated submenu when the user picked inside one, not the ++ // root. Swap in the matching node from the rebuilt tree (stable ++ // identifiers from the JS action ids) so that level updates in ++ // place; returning an unrelated menu instead makes UIKit render it ++ // as navigation into a foreign menu, with a stale or blank ++ // expanded-submenu header row above the actions. The root carries ++ // an auto-generated identifier that never matches, so it falls ++ // through to a children-only replacement. ++ interaction.updateVisibleMenu { [weak self] visibleMenu in ++ guard let self = self else { return visibleMenu } ++ if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) { ++ return replacement ++ } ++ return visibleMenu.replacingChildren(menu.children) ++ } ++ } ++ } ++ ++ private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? { ++ if menu.identifier == identifier { ++ return menu ++ } ++ for element in menu.children { ++ if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) { ++ return match ++ } + } ++ return nil + } + + private var longPressInteraction: UIContextMenuInteraction? diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 747f32c4e22..f2df3849992 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,7 +75,7 @@ patchedDependencies: '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa - '@react-native-menu/menu@2.0.0': 4396daa9a90dbd072ad9d0ebbb4a34eebe492d04b8b29422888fb5549d057776 + '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 @@ -225,7 +225,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=4396daa9a90dbd072ad9d0ebbb4a34eebe492d04b8b29422888fb5549d057776)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) @@ -14092,7 +14092,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=4396daa9a90dbd072ad9d0ebbb4a34eebe492d04b8b29422888fb5549d057776)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) From 825ad66bac74019c6651678c64e76213e20d6b36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 22:38:55 +0200 Subject: [PATCH 20/46] fix(mobile): close settings menu on nested picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS keeps the *navigated submenu* presented when a keepsMenuPresented action fires inside one, rendering an expanded-submenu header with no way to pop back to the root — chrome that earns nothing. Nested picks (model, select options, runtime) now close the menu like ChatGPT's picker; only top-level boolean toggles keep it presented, where the root refreshes in place cleanly. Verified on the iOS 26.5 simulator. Co-Authored-By: Claude Fable 5 --- .../threads/thread-settings-menu.test.ts | 16 +++++++++------- .../src/features/threads/thread-settings-menu.ts | 12 +++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts index 1c776057010..eb79df88009 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.test.ts @@ -213,23 +213,25 @@ describe("buildThreadSettingsMenu", () => { }); }); - it("keeps the menu presented for picks but not for the settings hand-off", () => { + it("keeps the menu presented only for top-level toggles", () => { const menu = buildThreadSettingsMenu(baseInput()); - const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; - expect( - modelItems.find((action) => action.title === "gpt-next")?.attributes?.keepsMenuPresented, - ).toBe(true); + // Root-level boolean toggles refresh in place with clean chrome. expect( menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, ).toBe(true); + + // Picks inside nested submenus close the menu: iOS keeps the *submenu* + // presented otherwise, rendering an expanded-submenu header with no way + // to pop back to the root. + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; const reasoningItems = menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; const runtimeItems = menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; expect( - [...reasoningItems, ...runtimeItems].every( - (action) => action.attributes?.keepsMenuPresented === true, + [...modelItems, ...reasoningItems, ...runtimeItems].every( + (action) => action.attributes?.keepsMenuPresented === undefined, ), ).toBe(true); diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts index 2f31ff10c5a..2178a973163 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.ts @@ -72,10 +72,11 @@ export function buildThreadSettingsMenu(input: { option.selection.instanceId === input.selectedModel?.instanceId && option.selection.model === input.selectedModel.model; - // Leaf picks keep the menu presented (iOS 16+) so several dimensions can be - // adjusted in one visit; the native side refreshes the visible menu when the - // rebuilt actions arrive, which restores the checkmarks. "All Settings…" is - // the exception — it hands off to the sheet, so the menu must close. + // Only top-level leaves (boolean toggles) keep the menu presented (iOS + // 16+): the root refreshes in place with clean chrome. A pick inside a + // nested submenu would keep THAT submenu on screen — iOS renders it with an + // expanded-submenu header and offers no way to pop back to the root — so + // nested picks close the menu instead. const keepPresented = { keepsMenuPresented: true } as const; const modelAction = (option: ModelOption, id: string): MenuAction => { @@ -85,7 +86,6 @@ export function buildThreadSettingsMenu(input: { title: option.label, ...(option.isDefault ? { subtitle: "Default" } : {}), state: isSelected(option) ? "on" : "off", - attributes: keepPresented, }; }; @@ -167,7 +167,6 @@ export function buildThreadSettingsMenu(input: { id, title: choice.label, state: choice.id === currentValue ? "on" : "off", - attributes: keepPresented, }; }); if (choices.length === 0) { @@ -196,7 +195,6 @@ export function buildThreadSettingsMenu(input: { id, title: choice.label, state: choice.mode === input.runtimeMode ? "on" : "off", - attributes: keepPresented, }; }), }); From f8d01865e43f599e5da085b42f5af7dfb01aeac1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 22:46:29 +0200 Subject: [PATCH 21/46] feat(mobile): flag nested settings picks keep-presented for UX comparison Re-enables keep-presented on nested picks behind NESTED_PICKS_KEEP_MENU_PRESENTED (currently true) so both variants can be compared on device: submenu stays open with in-place checkmark refresh vs close-on-pick. Flip the constant to switch. Co-Authored-By: Claude Fable 5 --- .../threads/thread-settings-menu.test.ts | 28 +++++++++++-------- .../features/threads/thread-settings-menu.ts | 24 ++++++++++++---- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts index eb79df88009..c6af3bba294 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; +import { + buildThreadSettingsMenu, + NESTED_PICKS_KEEP_MENU_PRESENTED, + type ThreadSettingsMenuEvent, +} from "./thread-settings-menu"; function modelOption( model: string, @@ -213,27 +217,29 @@ describe("buildThreadSettingsMenu", () => { }); }); - it("keeps the menu presented only for top-level toggles", () => { + it("keeps the menu presented for top-level toggles and per-flag nested picks", () => { const menu = buildThreadSettingsMenu(baseInput()); - // Root-level boolean toggles refresh in place with clean chrome. + // Root-level boolean toggles refresh in place with clean chrome, so they + // always keep the menu presented. expect( menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, ).toBe(true); - // Picks inside nested submenus close the menu: iOS keeps the *submenu* - // presented otherwise, rendering an expanded-submenu header with no way - // to pop back to the root. + // Nested picks follow the UX-comparison flag. + const expected = NESTED_PICKS_KEEP_MENU_PRESENTED ? true : undefined; const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; const reasoningItems = menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; const runtimeItems = menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; - expect( - [...modelItems, ...reasoningItems, ...runtimeItems].every( - (action) => action.attributes?.keepsMenuPresented === undefined, - ), - ).toBe(true); + const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter( + (action) => action.subactions === undefined, + ); + expect(nestedPicks.length).toBeGreaterThan(0); + expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe( + true, + ); // The sheet hand-off must dismiss the menu before presenting. expect(menu.actions.at(-1)?.subactions?.[0]?.attributes).toBeUndefined(); diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts index 2178a973163..30bd2a1bc9d 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.ts @@ -36,6 +36,16 @@ export function selectableChoices( ); } +/** + * UX comparison toggle. When true, a pick inside a nested submenu keeps the + * menu presented — iOS keeps *that submenu* on screen with an + * expanded-submenu header (there is no way to pop back to the root), and the + * checkmark/header refresh in place. When false, nested picks close the menu + * (ChatGPT-style). Top-level boolean toggles keep the menu presented either + * way, since the root refreshes with clean chrome. + */ +export const NESTED_PICKS_KEEP_MENU_PRESENTED = true; + export type ThreadSettingsMenuEvent = | { readonly type: "select-model"; readonly option: ModelOption } | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } @@ -72,12 +82,13 @@ export function buildThreadSettingsMenu(input: { option.selection.instanceId === input.selectedModel?.instanceId && option.selection.model === input.selectedModel.model; - // Only top-level leaves (boolean toggles) keep the menu presented (iOS - // 16+): the root refreshes in place with clean chrome. A pick inside a - // nested submenu would keep THAT submenu on screen — iOS renders it with an - // expanded-submenu header and offers no way to pop back to the root — so - // nested picks close the menu instead. + // Top-level leaves (boolean toggles) always keep the menu presented (iOS + // 16+): the root refreshes in place with clean chrome. Nested picks follow + // NESTED_PICKS_KEEP_MENU_PRESENTED. const keepPresented = { keepsMenuPresented: true } as const; + const nestedPickAttributes = NESTED_PICKS_KEEP_MENU_PRESENTED + ? { attributes: keepPresented } + : {}; const modelAction = (option: ModelOption, id: string): MenuAction => { events.set(id, { type: "select-model", option }); @@ -86,6 +97,7 @@ export function buildThreadSettingsMenu(input: { title: option.label, ...(option.isDefault ? { subtitle: "Default" } : {}), state: isSelected(option) ? "on" : "off", + ...nestedPickAttributes, }; }; @@ -167,6 +179,7 @@ export function buildThreadSettingsMenu(input: { id, title: choice.label, state: choice.id === currentValue ? "on" : "off", + ...nestedPickAttributes, }; }); if (choices.length === 0) { @@ -195,6 +208,7 @@ export function buildThreadSettingsMenu(input: { id, title: choice.label, state: choice.mode === input.runtimeMode ? "on" : "off", + ...nestedPickAttributes, }; }), }); From 2f3bd0a9f6ebef9c80172835e723f6405183cf0f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 22:50:54 +0200 Subject: [PATCH 22/46] feat(mobile): drop All Settings escape hatch from the thread menu A thread is bound to one harness, so the native menu now covers the sheet's entire surface for existing threads (models incl. legacy, select and boolean options, runtime). The sheet stays as the Android trigger surface and the new-task draft picker; iOS threads no longer need the hand-off row. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadComposer.tsx | 5 ---- .../threads/thread-settings-menu.test.ts | 16 ++---------- .../features/threads/thread-settings-menu.ts | 26 ++++++------------- 3 files changed, 10 insertions(+), 37 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 12190b52d51..b68256ff833 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -279,7 +279,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer editorRef: inputRef, isEditorFocused: isFocused, }); - const openSettingsSheet = settingsSheetPresentation.open; const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); const { onExpandedChange } = props; @@ -673,16 +672,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer void Haptics.selectionAsync(); onUpdateRuntimeMode(event.mode); return; - case "open-settings": - openSettingsSheet(); - return; } }, [ currentModelSelection, onUpdateModelSelection, onUpdateRuntimeMode, - openSettingsSheet, providerOptionDescriptors, settingsMenu, ], diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts index c6af3bba294..3e4f82e9833 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.test.ts @@ -85,7 +85,7 @@ function eventFor(menu: ReturnType, id: string | } describe("buildThreadSettingsMenu", () => { - it("orders the top level as model, options, runtime, settings escape hatch", () => { + it("orders the top level as model, options, runtime", () => { const menu = buildThreadSettingsMenu(baseInput()); expect(menu.actions.map((action) => action.title)).toEqual([ @@ -93,14 +93,7 @@ describe("buildThreadSettingsMenu", () => { "Reasoning", "Fast mode", "Runtime", - "", ]); - const settingsSection = menu.actions.at(-1); - expect(settingsSection?.displayInline).toBe(true); - expect(settingsSection?.subactions?.map((action) => action.title)).toEqual(["All Settings…"]); - expect(eventFor(menu, settingsSection?.subactions?.[0]?.id)).toEqual({ - type: "open-settings", - }); }); it("summarizes the current choice on each submenu row", () => { @@ -240,9 +233,6 @@ describe("buildThreadSettingsMenu", () => { expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe( true, ); - - // The sheet hand-off must dismiss the menu before presenting. - expect(menu.actions.at(-1)?.subactions?.[0]?.attributes).toBeUndefined(); }); it("sections models by provider only when multiple groups are offered", () => { @@ -291,8 +281,6 @@ describe("buildThreadSettingsMenu", () => { for (const id of leafIds) { expect(menu.events.get(id), `missing event for ${id}`).toBeDefined(); } - expect(eventTypes(menu)).toEqual( - new Set(["select-model", "set-option", "set-runtime", "open-settings"]), - ); + expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"])); }); }); diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts index 30bd2a1bc9d..25f2016ef1f 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.ts @@ -49,8 +49,7 @@ export const NESTED_PICKS_KEEP_MENU_PRESENTED = true; export type ThreadSettingsMenuEvent = | { readonly type: "select-model"; readonly option: ModelOption } | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } - | { readonly type: "set-runtime"; readonly mode: RuntimeMode } - | { readonly type: "open-settings" }; + | { readonly type: "set-runtime"; readonly mode: RuntimeMode }; export type ThreadSettingsMenu = { readonly actions: MenuAction[]; @@ -59,15 +58,15 @@ export type ThreadSettingsMenu = { }; /** - * Native menu equivalent of the thread settings sheet for the everyday - * adjustments (model, select/boolean provider options, runtime mode). The - * menu presents from the composer pill without resigning the keyboard, so the - * common flow never bounces focus; "All Settings…" falls back to the sheet - * for anything richer. + * Native menu replacement for the thread settings sheet (model, select and + * boolean provider options, runtime mode). The menu presents from the + * composer pill without resigning the keyboard, so adjusting settings never + * bounces focus. A thread is bound to one harness, so the menu covers the + * sheet's full surface for existing threads; the sheet remains the Android + * and new-task-draft surface. * * Selections apply immediately — the sheet's stage-then-Save flow only exists - * because the sheet batches a model change with its option edits, and a menu - * closes after each pick anyway. + * because the sheet batches a model change with its option edits. */ export function buildThreadSettingsMenu(input: { readonly providerGroups: ReadonlyArray; @@ -213,14 +212,5 @@ export function buildThreadSettingsMenu(input: { }), }); - events.set("open-settings", { type: "open-settings" }); - actions.push({ - // Inline single-item section renders the system divider above the row. - id: "settings-section", - title: "", - displayInline: true, - subactions: [{ id: "open-settings", title: "All Settings…", image: "slider.horizontal.3" }], - }); - return { actions, events }; } From 544e150a151034d3c0e05189b724be248b101102 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 23:05:45 +0200 Subject: [PATCH 23/46] feat(mobile): settle on close-on-pick for nested settings menu picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the UX comparison flag and hard-codes the ChatGPT-style behavior: picks inside nested submenus (model, options, runtime) close the menu, top-level boolean toggles keep it presented with in-place refresh. The stay-open variant fought UIKit twice — the expanded-submenu header and the bottom-anchored collapse dropping by the levels' height difference — neither of which is fixable app-side. Co-Authored-By: Claude Fable 5 --- .../threads/thread-settings-menu.test.ts | 16 ++++++------- .../features/threads/thread-settings-menu.ts | 24 ++++--------------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts index 3e4f82e9833..078be2df11b 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.test.ts @@ -3,11 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import { - buildThreadSettingsMenu, - NESTED_PICKS_KEEP_MENU_PRESENTED, - type ThreadSettingsMenuEvent, -} from "./thread-settings-menu"; +import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; function modelOption( model: string, @@ -210,17 +206,19 @@ describe("buildThreadSettingsMenu", () => { }); }); - it("keeps the menu presented for top-level toggles and per-flag nested picks", () => { + it("keeps the menu presented only for top-level toggles", () => { const menu = buildThreadSettingsMenu(baseInput()); // Root-level boolean toggles refresh in place with clean chrome, so they - // always keep the menu presented. + // keep the menu presented. expect( menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, ).toBe(true); - // Nested picks follow the UX-comparison flag. - const expected = NESTED_PICKS_KEEP_MENU_PRESENTED ? true : undefined; + // Picks inside nested submenus close the menu: staying presented leaves + // the submenu on screen with an expanded-submenu header, and the + // bottom-anchored collapse back out drops by the levels' height delta. + const expected = undefined; const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; const reasoningItems = menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts index 25f2016ef1f..31b1c021c46 100644 --- a/apps/mobile/src/features/threads/thread-settings-menu.ts +++ b/apps/mobile/src/features/threads/thread-settings-menu.ts @@ -36,16 +36,6 @@ export function selectableChoices( ); } -/** - * UX comparison toggle. When true, a pick inside a nested submenu keeps the - * menu presented — iOS keeps *that submenu* on screen with an - * expanded-submenu header (there is no way to pop back to the root), and the - * checkmark/header refresh in place. When false, nested picks close the menu - * (ChatGPT-style). Top-level boolean toggles keep the menu presented either - * way, since the root refreshes with clean chrome. - */ -export const NESTED_PICKS_KEEP_MENU_PRESENTED = true; - export type ThreadSettingsMenuEvent = | { readonly type: "select-model"; readonly option: ModelOption } | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } @@ -81,13 +71,12 @@ export function buildThreadSettingsMenu(input: { option.selection.instanceId === input.selectedModel?.instanceId && option.selection.model === input.selectedModel.model; - // Top-level leaves (boolean toggles) always keep the menu presented (iOS - // 16+): the root refreshes in place with clean chrome. Nested picks follow - // NESTED_PICKS_KEEP_MENU_PRESENTED. + // Only top-level leaves (boolean toggles) keep the menu presented (iOS + // 16+): the root refreshes in place with clean chrome. Picks inside nested + // submenus close the menu — keeping the submenu presented renders an + // expanded-submenu header with no way to pop back to the root, and the + // bottom-anchored collapse back out travels the levels' height difference. const keepPresented = { keepsMenuPresented: true } as const; - const nestedPickAttributes = NESTED_PICKS_KEEP_MENU_PRESENTED - ? { attributes: keepPresented } - : {}; const modelAction = (option: ModelOption, id: string): MenuAction => { events.set(id, { type: "select-model", option }); @@ -96,7 +85,6 @@ export function buildThreadSettingsMenu(input: { title: option.label, ...(option.isDefault ? { subtitle: "Default" } : {}), state: isSelected(option) ? "on" : "off", - ...nestedPickAttributes, }; }; @@ -178,7 +166,6 @@ export function buildThreadSettingsMenu(input: { id, title: choice.label, state: choice.id === currentValue ? "on" : "off", - ...nestedPickAttributes, }; }); if (choices.length === 0) { @@ -207,7 +194,6 @@ export function buildThreadSettingsMenu(input: { id, title: choice.label, state: choice.mode === input.runtimeMode ? "on" : "off", - ...nestedPickAttributes, }; }), }); From 54671506e0341e3a68f758b2dcef95c70fb09d3f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 23:29:26 +0200 Subject: [PATCH 24/46] style(mobile): shrink scroll-to-end button and tuck it near the composer 36pt circle instead of 44pt, floating 56pt above the composer overlay instead of 112pt. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 40a06c206be..aa134dd6344 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -436,14 +436,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {showScrollToEndButton ? ( From 1092d7ed403821f9d9a4e5a29f24cbdb32b074fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 23:36:50 +0200 Subject: [PATCH 25/46] style(mobile): glass scroll-to-end button hugging the composer Liquid-glass circle on supported devices (36pt, interactive shimmer), 44pt above the composer overlay; the bordered card circle stays as the fallback. Co-Authored-By: Claude Fable 5 --- .../features/threads/ThreadDetailScreen.tsx | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index aa134dd6344..c6fea4f6701 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -27,7 +27,14 @@ import { useRef, useState, } from "react"; -import { Platform, useWindowDimensions, View, type GestureResponderEvent } from "react-native"; +import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; +import { + Platform, + useColorScheme, + useWindowDimensions, + View, + type GestureResponderEvent, +} from "react-native"; import { KeyboardController, KeyboardStickyView, @@ -349,6 +356,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }, [freeze, scrollMessageToEnd]); const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; + const isDarkMode = useColorScheme() === "dark"; const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { feedTouchStartRef.current = { @@ -436,17 +444,34 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {showScrollToEndButton ? ( - + {isLiquidGlassSupported ? ( + + + + ) : ( + + )} ) : null} From 6b4b5d4ab1941188ac725651afb35ffdf0da9374 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 23:39:04 +0200 Subject: [PATCH 26/46] style(mobile): center the chevron inside the glass scroll button Interactive liquid glass can render larger than the requested 36pt box (minimum touch size), which left the pill anchored top-left; center it instead of assuming it fills the glass. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadDetailScreen.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index c6fea4f6701..bfafe8e9184 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -453,7 +453,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread colorScheme={isDarkMode ? "dark" : "light"} effect="regular" interactive - style={{ borderRadius: 18, height: 36, overflow: "hidden", width: 36 }} + // Interactive glass can render larger than the requested + // box (minimum touch size), so center the pill instead of + // relying on it filling the glass exactly. + style={{ + alignItems: "center", + borderRadius: 18, + height: 36, + justifyContent: "center", + overflow: "hidden", + width: 36, + }} > Date: Mon, 10 Aug 2026 23:45:26 +0200 Subject: [PATCH 27/46] feat(mobile): collapsible questionnaire that replaces the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending user-input card now owns the input surface while expanded: the composer hides (display none, so drafts and editor state survive) and the card pads the home indicator itself. A header tap or the chevron collapses it to a compact 'User input needed · N questions' pill so the transcript is readable and the composer returns for free-text steering or stopping the turn; collapse releases the keyboard with the hidden custom answer inputs. Collapse state is keyed by request id so a new request re-expands automatically. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 52 ++++++++-- .../features/threads/ThreadDetailScreen.tsx | 94 +++++++++++++------ 2 files changed, 110 insertions(+), 36 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 1dc28a06fa8..1b6fbff1175 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,8 +1,10 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; import { Pressable, ScrollView, View } from "react-native"; +import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { cn } from "../../lib/cn"; +import { useThemeColor } from "../../lib/useThemeColor"; import { isPendingUserInputOptionSelected, type PendingUserInput, @@ -12,6 +14,8 @@ import { export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; readonly maxHeight: number; + readonly collapsed: boolean; + readonly onToggleCollapsed: () => void; readonly drafts: Record; readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; @@ -29,6 +33,30 @@ export interface PendingUserInputCardProps { } export function PendingUserInputCard(props: PendingUserInputCardProps) { + const iconSubtle = useThemeColor("--color-icon-subtle"); + const questionCount = props.pendingUserInput.questions.length; + + if (props.collapsed) { + return ( + + + User input needed + + + {questionCount} question{questionCount === 1 ? "" : "s"} + + + + ); + } + // The surface is opaque on purpose: the card floats over the thread feed // with no blur behind it, so a translucent background renders the questions // on top of whatever message happens to sit underneath. @@ -37,12 +65,24 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { 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" style={{ maxHeight: props.maxHeight }} > - - User input needed - - - Fill in the pending answers - + + + + User input needed + + + Fill in the pending answers + + + + + + (null); + const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + const userInputCollapsed = + activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; + const userInputExpanded = activeUserInputRequestId !== null && !userInputCollapsed; + const handleToggleUserInputCollapsed = useCallback(() => { + if (activeUserInputRequestId === null) { + return; + } + if (userInputCollapsed) { + setCollapsedUserInputRequestId(null); + } else { + // Collapsing hides the custom-answer inputs; release the keyboard with + // them instead of leaving it up over a dead responder. + Keyboard.dismiss(); + setCollapsedUserInputRequestId(activeUserInputRequestId); + } + }, [activeUserInputRequestId, userInputCollapsed]); const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ windowHeight, keyboardHeight: isKeyboardVisible ? keyboardHeight : 0, navigationHeaderHeight, - composerOverlapHeight, + // With the composer hidden, only its bottom inset still overlaps. + composerOverlapHeight: userInputExpanded ? composerBottomInset : composerOverlapHeight, }); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the @@ -488,6 +513,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingApproval || props.activePendingUserInput ? ( @@ -502,6 +530,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread - + {/* Hidden (not unmounted) while the questionnaire owns the input + surface, so composer drafts and editor state survive. */} + + + ) : null} From dd0cd4fc6fe343e7baac7c6158a605abd45d9843 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 10 Aug 2026 23:53:28 +0200 Subject: [PATCH 28/46] feat(mobile): questionnaire owns the composer slot in both states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsing the pending-input card no longer swaps the composer back in mid-animation — that flip is what made the collapse fight the keyboard dismissal. While a request is pending the composer stays hidden; the collapsed state is a composer-style bar in the same slot (question count, expand chevron, and its own stop control) and both states share one animated root so a layout transition morphs bar ↔ card and glides the keyboard-driven max-height changes instead of snapping. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 62 ++++++++++++++----- .../features/threads/ThreadDetailScreen.tsx | 33 ++++++---- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 1b6fbff1175..4fcdc8b1abb 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,8 +1,10 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; import { Pressable, ScrollView, View } from "react-native"; +import Animated, { LinearTransition } from "react-native-reanimated"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; import { useThemeColor } from "../../lib/useThemeColor"; import { @@ -16,6 +18,8 @@ export interface PendingUserInputCardProps { readonly maxHeight: number; readonly collapsed: boolean; readonly onToggleCollapsed: () => void; + /** Renders a stop control on the collapsed bar, which replaces the composer. */ + readonly onStopThread?: () => void; readonly drafts: Record; readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; @@ -32,28 +36,51 @@ export interface PendingUserInputCardProps { readonly onSubmit: () => Promise; } +/** + * Both states render the same animated root so React keeps one view and the + * layout transition morphs the frame between the composer-style bar and the + * full card (and glides the keyboard-driven max-height changes) instead of + * snapping. + */ +const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); + export function PendingUserInputCard(props: PendingUserInputCardProps) { const iconSubtle = useThemeColor("--color-icon-subtle"); const questionCount = props.pendingUserInput.questions.length; if (props.collapsed) { return ( - - - User input needed - - - {questionCount} question{questionCount === 1 ? "" : "s"} - - - + + + User input needed + + + {questionCount} question{questionCount === 1 ? "" : "s"} + + + + + {props.onStopThread ? ( + + ) : null} + ); } @@ -61,7 +88,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { // with no blur behind it, so a translucent background renders the questions // on top of whatever message happens to sit underneath. return ( - @@ -160,6 +188,6 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { > Submit answers - + ); } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 83baca1ba3c..54bfd78e272 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -234,16 +234,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; - // While the questionnaire is expanded it IS the input surface and the - // composer hides; collapsing it (keyed by request id, so a new request - // re-expands automatically) brings the composer back for free-text - // steering or stopping the turn. + // While a user-input request is pending, the questionnaire owns the + // composer slot outright: expanded it is the full card, collapsed it is a + // composer-style bar in the same place (with its own stop control). The + // composer never mounts into the transition, which keeps the collapse and + // keyboard animations coherent. Collapse state is keyed by request id so a + // new request re-expands automatically. const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; - const userInputExpanded = activeUserInputRequestId !== null && !userInputCollapsed; const handleToggleUserInputCollapsed = useCallback(() => { if (activeUserInputRequestId === null) { return; @@ -261,8 +262,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread windowHeight, keyboardHeight: isKeyboardVisible ? keyboardHeight : 0, navigationHeaderHeight, - // With the composer hidden, only its bottom inset still overlaps. - composerOverlapHeight: userInputExpanded ? composerBottomInset : composerOverlapHeight, + // With the composer out of the slot, only its bottom inset still overlaps. + composerOverlapHeight: + activeUserInputRequestId !== null ? composerBottomInset : composerOverlapHeight, }); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the @@ -513,9 +515,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingApproval || props.activePendingUserInput ? ( @@ -532,6 +538,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread maxHeight={pendingUserInputMaxHeight} collapsed={userInputCollapsed} onToggleCollapsed={handleToggleUserInputCollapsed} + onStopThread={props.onStopThread} drafts={props.activePendingUserInputDrafts} answers={props.activePendingUserInputAnswers} respondingUserInputId={props.respondingUserInputId} @@ -544,9 +551,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} - {/* Hidden (not unmounted) while the questionnaire owns the input - surface, so composer drafts and editor state survive. */} - + {/* Hidden (not unmounted) while a user-input request owns the + composer slot, so composer drafts and editor state survive. */} + Date: Mon, 10 Aug 2026 23:59:07 +0200 Subject: [PATCH 29/46] fix(mobile): crossfade the questionnaire bar-card swap instead of morphing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frame-morphing one view between the composer-style bar and the full card stranded the card mid-flight detached from the bottom slot (top edge snapping to final position while height grew). Keyed remounts crossfade the swap — the card rises in from the slot, the outgoing view fades in place — and the layout transition remains only on the stable expanded card, gliding keyboard-driven max-height changes. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 4fcdc8b1abb..2be02834199 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,6 +1,6 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; import { Pressable, ScrollView, View } from "react-native"; -import Animated, { LinearTransition } from "react-native-reanimated"; +import Animated, { FadeIn, FadeInUp, FadeOut, LinearTransition } from "react-native-reanimated"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; @@ -37,10 +37,11 @@ export interface PendingUserInputCardProps { } /** - * Both states render the same animated root so React keeps one view and the - * layout transition morphs the frame between the composer-style bar and the - * full card (and glides the keyboard-driven max-height changes) instead of - * snapping. + * The bar and the card swap via keyed remounts with enter/exit animations — + * frame-morphing one view between shapes this different strands it mid-flight + * detached from the bottom slot. The layout transition stays on the expanded + * card only, where it glides the keyboard-driven max-height changes of a + * stable view. */ const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); @@ -51,7 +52,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { if (props.collapsed) { return ( Date: Tue, 11 Aug 2026 00:11:45 +0200 Subject: [PATCH 30/46] fix(mobile): track keyboard animation for the questionnaire height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card's max height was derived from the binary keyboard visibility, which only flips on keyboardDidHide — so after riding the keyboard down via the sticky view, the card corrected its height in a second, separate animation. Drive max height from the keyboard controller's animated height in a worklet instead: the card now resizes frame-by-frame in sync with the keyboard in both directions, and the layout transition (which would chase the continuous animated style) is gone. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 17 +++++------ .../features/threads/ThreadDetailScreen.tsx | 29 ++++++++++++------- .../threads/pendingUserInputLayout.ts | 1 + 3 files changed, 27 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 2be02834199..cd747c5fc09 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,6 +1,7 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; +import type { ComponentProps } from "react"; import { Pressable, ScrollView, View } from "react-native"; -import Animated, { FadeIn, FadeInUp, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { FadeIn, FadeInUp, FadeOut } from "react-native-reanimated"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; @@ -15,7 +16,8 @@ import { export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; - readonly maxHeight: number; + /** Animated max-height tracking the keyboard, applied to the expanded card. */ + readonly maxHeightStyle: ComponentProps["style"]; readonly collapsed: boolean; readonly onToggleCollapsed: () => void; /** Renders a stop control on the collapsed bar, which replaces the composer. */ @@ -39,12 +41,10 @@ export interface PendingUserInputCardProps { /** * The bar and the card swap via keyed remounts with enter/exit animations — * frame-morphing one view between shapes this different strands it mid-flight - * detached from the bottom slot. The layout transition stays on the expanded - * card only, where it glides the keyboard-driven max-height changes of a - * stable view. + * detached from the bottom slot. The expanded card's height needs no layout + * transition: its animated max-height style already tracks the keyboard + * frame-by-frame. */ -const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); - export function PendingUserInputCard(props: PendingUserInputCardProps) { const iconSubtle = useThemeColor("--color-icon-subtle"); const questionCount = props.pendingUserInput.questions.length; @@ -95,9 +95,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { key="pending-user-input-card" entering={FadeInUp.duration(220)} exiting={FadeOut.duration(120)} - layout={CARD_LAYOUT_TRANSITION} 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" - style={{ maxHeight: props.maxHeight }} + style={props.maxHeightStyle} > state.isVisible); const windowHeight = useWindowDimensions().height; - const keyboardHeight = useKeyboardState((state) => state.height); const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); @@ -258,14 +258,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread setCollapsedUserInputRequestId(activeUserInputRequestId); } }, [activeUserInputRequestId, userInputCollapsed]); - const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ - windowHeight, - keyboardHeight: isKeyboardVisible ? keyboardHeight : 0, - navigationHeaderHeight, - // With the composer out of the slot, only its bottom inset still overlaps. - composerOverlapHeight: - activeUserInputRequestId !== null ? composerBottomInset : composerOverlapHeight, - }); + // The card's max height tracks the keyboard's ANIMATED height, so it + // resizes frame-by-frame with the keyboard instead of correcting itself in + // a second animation once the hide settles (the binary visibility state + // only flips on keyboardDidHide). + const keyboardAnimation = useReanimatedKeyboardAnimation(); + const pendingUserInputMaxHeightStyle = useAnimatedStyle(() => ({ + maxHeight: derivePendingUserInputMaxHeight({ + windowHeight, + keyboardHeight: Math.abs(keyboardAnimation.height.value), + navigationHeaderHeight, + // The questionnaire owns the composer slot, so only the composer's + // bottom inset still overlaps. + composerOverlapHeight: composerBottomInset, + }), + })); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -535,7 +542,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingUserInput ? ( Date: Tue, 11 Aug 2026 00:22:55 +0200 Subject: [PATCH 31/46] fix(mobile): reserve keyboard space so the questionnaire never resizes with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The keyboard-open transitions desynced because the sticky translation is a transform (applied same-frame on the UI thread) while the card's keyboard-tracking max height is a layout prop (a Yoga pass behind) — the card flashed over the nav header at its stale height, then left a gap above the still-rising keyboard. The card now reserves keyboard space permanently (last observed height, estimated before the first open), so keyboard open/close is pure translation with no layout animation at all; height changes only on rare discrete corrections, smoothed by the layout transition. The collapse ghost fade is shortened to 90ms since it hangs over the transcript while the feed inset snaps beneath it. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 24 +++++---- .../features/threads/ThreadDetailScreen.tsx | 50 ++++++++++++------- .../threads/pendingUserInputLayout.ts | 8 ++- 3 files changed, 53 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index cd747c5fc09..4ff1b18ee12 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,7 +1,6 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; -import type { ComponentProps } from "react"; import { Pressable, ScrollView, View } from "react-native"; -import Animated, { FadeIn, FadeInUp, FadeOut } from "react-native-reanimated"; +import Animated, { FadeIn, FadeInUp, FadeOut, LinearTransition } from "react-native-reanimated"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; @@ -16,8 +15,12 @@ import { export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; - /** Animated max-height tracking the keyboard, applied to the expanded card. */ - readonly maxHeightStyle: ComponentProps["style"]; + /** + * Constant while a request is pending (it reserves keyboard space), so the + * keyboard transition is pure translation; changes only on rare discrete + * corrections, which the layout transition smooths. + */ + readonly maxHeight: number; readonly collapsed: boolean; readonly onToggleCollapsed: () => void; /** Renders a stop control on the collapsed bar, which replaces the composer. */ @@ -41,10 +44,10 @@ export interface PendingUserInputCardProps { /** * The bar and the card swap via keyed remounts with enter/exit animations — * frame-morphing one view between shapes this different strands it mid-flight - * detached from the bottom slot. The expanded card's height needs no layout - * transition: its animated max-height style already tracks the keyboard - * frame-by-frame. + * detached from the bottom slot. */ +const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); + export function PendingUserInputCard(props: PendingUserInputCardProps) { const iconSubtle = useThemeColor("--color-icon-subtle"); const questionCount = props.pendingUserInput.questions.length; @@ -94,9 +97,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ({ - maxHeight: derivePendingUserInputMaxHeight({ - windowHeight, - keyboardHeight: Math.abs(keyboardAnimation.height.value), - navigationHeaderHeight, - // The questionnaire owns the composer slot, so only the composer's - // bottom inset still overlaps. - composerOverlapHeight: composerBottomInset, - }), - })); + // The card's height RESERVES keyboard space at all times instead of + // tracking the keyboard: transforms (the sticky translation) apply + // same-frame on the UI thread while layout props lag a Yoga pass behind, + // so any height that follows the keyboard flashes the card over the nav + // header on the way up. With a constant height the keyboard transition is + // pure translation — frame-perfect by construction — and the resting card + // stays compact over the transcript. Before the first open the reserve is + // an estimate; once a real height is known the card corrects once, + // discretely. + const measuredKeyboardHeight = useKeyboardState((state) => state.height); + const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); + useEffect(() => { + if (measuredKeyboardHeight > 0 && measuredKeyboardHeight !== lastKnownKeyboardHeight) { + setLastKnownKeyboardHeight(measuredKeyboardHeight); + } + }, [lastKnownKeyboardHeight, measuredKeyboardHeight]); + const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ + windowHeight, + keyboardHeight: + lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT, + navigationHeaderHeight, + // The questionnaire owns the composer slot, so only the composer's + // bottom inset still overlaps. + composerOverlapHeight: composerBottomInset, + }); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -542,7 +554,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingUserInput ? ( Date: Tue, 11 Aug 2026 00:48:32 +0200 Subject: [PATCH 32/46] fix(mobile): keep the feed still during questionnaire collapse and expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapse/expand swap changed the overlay's measured height by the card-vs-bar delta in one frame, snapping the thread feed's bottom inset — the transcript teleported with content clipped under the bar. The bar is now the permanent in-flow footprint and the expanded card an absolutely positioned overlay rising above it: the measured height never changes on toggle, so the feed does not move at all, and request arrival/resolution also stops jumping since the footprint matches the composer's size. The card sinks back into the bar on collapse (FadeOutDown) instead of ghosting translucently over the transcript. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 245 +++++++++--------- 1 file changed, 124 insertions(+), 121 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 4ff1b18ee12..99907460a84 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,6 +1,6 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; import { Pressable, ScrollView, View } from "react-native"; -import Animated, { FadeIn, FadeInUp, FadeOut, LinearTransition } from "react-native-reanimated"; +import Animated, { FadeInUp, FadeOutDown, LinearTransition } from "react-native-reanimated"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; @@ -42,9 +42,11 @@ export interface PendingUserInputCardProps { } /** - * The bar and the card swap via keyed remounts with enter/exit animations — - * frame-morphing one view between shapes this different strands it mid-flight - * detached from the bottom slot. + * The collapsed bar is the PERMANENT in-flow footprint — the expanded card is + * an absolutely-positioned overlay rising above it. The overlay's measured + * height (which drives the thread feed's bottom inset) therefore never + * changes on collapse/expand, so the transcript stays perfectly still while + * the card animates over it. */ const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); @@ -52,12 +54,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const iconSubtle = useThemeColor("--color-icon-subtle"); const questionCount = props.pendingUserInput.questions.length; - if (props.collapsed) { - return ( - + ) : null} - - ); - } - - // The surface is opaque on purpose: the card floats over the thread feed - // with no blur behind it, so a translucent background renders the questions - // on top of whatever message happens to sit underneath. - return ( - - - - - User input needed - - - Fill in the pending answers - - - - - - - - {props.pendingUserInput.questions.map((question) => { - const draft = props.drafts[question.id]; - return ( - - - {question.header} + + {props.collapsed ? null : ( + // The surface is opaque on purpose: the card floats over the thread + // feed with no blur behind it, so a translucent background renders + // the questions on top of whatever message happens to sit underneath. + + + + + User input needed - - {question.question} + + Fill in the pending answers - - {question.options.map((option) => { - const selected = isPendingUserInputOptionSelected(draft, option.label); - return ( - - props.onSelectOption( - props.pendingUserInput.requestId, - question, - option.label, - ) - } - > - - {option.label} - - - ); - })} - - - props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) - } - placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" - /> - ); - })} - - void props.onSubmit()} - > - Submit answers - - + + + + + + {props.pendingUserInput.questions.map((question) => { + const draft = props.drafts[question.id]; + return ( + + + {question.header} + + + {question.question} + + + {question.options.map((option) => { + const selected = isPendingUserInputOptionSelected(draft, option.label); + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question, + option.label, + ) + } + > + + {option.label} + + + ); + })} + + + props.onChangeCustomAnswer( + props.pendingUserInput.requestId, + question.id, + value, + ) + } + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + /> + + ); + })} + + void props.onSubmit()} + > + Submit answers + + + )} + ); } From eea0156d64f164801d9fab272467df6600fe1301 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 01:07:06 +0200 Subject: [PATCH 33/46] feat(mobile): keep the end of the chat visible above the expanded questionnaire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay architecture left the feed's end inset at the bar footprint, hiding the last ~300pt of transcript behind the expanded card. The card now reports how far it extends above the bar, and that coverage is added to the feed's end inset through a shared value animated with the same 220ms timing as the card's rise/sink — the chat end glides above the card on expand and back down on collapse, keyboard-style, with no layout snap anywhere. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 32 +++++++++++++++++- .../features/threads/ThreadDetailScreen.tsx | 33 +++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 99907460a84..0f033335db7 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,5 +1,6 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; -import { Pressable, ScrollView, View } from "react-native"; +import { useCallback, useRef } from "react"; +import { Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { FadeInUp, FadeOutDown, LinearTransition } from "react-native-reanimated"; import { SymbolView } from "../../components/AppSymbol"; @@ -25,6 +26,12 @@ export interface PendingUserInputCardProps { readonly onToggleCollapsed: () => void; /** Renders a stop control on the collapsed bar, which replaces the composer. */ readonly onStopThread?: () => void; + /** + * Reports how far the expanded card extends above the bar footprint, so the + * host can add that coverage to the thread feed's end inset (animated) and + * keep the end of the chat visible above the card. + */ + readonly onCardCoverageChange?: (coverage: number) => void; readonly drafts: Record; readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; @@ -54,9 +61,31 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const iconSubtle = useThemeColor("--color-icon-subtle"); const questionCount = props.pendingUserInput.questions.length; + const onCardCoverageChange = props.onCardCoverageChange; + const barHeightRef = useRef(0); + const cardHeightRef = useRef(0); + const notifyCoverage = useCallback(() => { + onCardCoverageChange?.(Math.max(0, cardHeightRef.current - barHeightRef.current)); + }, [onCardCoverageChange]); + const handleBarLayout = useCallback( + (event: LayoutChangeEvent) => { + barHeightRef.current = event.nativeEvent.layout.height; + notifyCoverage(); + }, + [notifyCoverage], + ); + const handleCardLayout = useCallback( + (event: LayoutChangeEvent) => { + cardHeightRef.current = event.nativeEvent.layout.height; + notifyCoverage(); + }, + [notifyCoverage], + ); + return ( contentInsetEndAdjustment.value + pendingCardInsetExtra.value, + (value) => { + combinedContentInsetEndAdjustment.value = value; + }, + ); + const userInputInsetExtraTarget = + activeUserInputRequestId !== null && !userInputCollapsed ? userInputCardCoverage : 0; + useEffect(() => { + pendingCardInsetExtra.value = withTiming(userInputInsetExtraTarget, { duration: 220 }); + }, [pendingCardInsetExtra, userInputInsetExtraTarget]); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; @@ -457,7 +485,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} - contentInsetEndAdjustment={contentInsetEndAdjustment} + contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} contentMaxWidth={contentMaxWidth} @@ -558,6 +586,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread collapsed={userInputCollapsed} onToggleCollapsed={handleToggleUserInputCollapsed} onStopThread={props.onStopThread} + onCardCoverageChange={setUserInputCardCoverage} drafts={props.activePendingUserInputDrafts} answers={props.activePendingUserInputAnswers} respondingUserInputId={props.respondingUserInputId} From 278ac96040f9480409634a96d067b854546b7605 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 01:26:53 +0200 Subject: [PATCH 34/46] chore(mobile): bump @legendapp/list to 3.3.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latest published version (the npm beta dist-tag is stale, predating 3.3.3). Brings upstream fixes for maintainScrollAtEnd staying pinned during row measurement, scroll corrections preserving anchoredEndSpace padding, end-anchoring accuracy after measurement changes, and programmatic scroll settling — all directly relevant to the thread feed's end-anchoring. Our keyboard/scroll-inset patch is ported onto 3.3.5; no hunks were absorbed upstream. Mobile typecheck and the live-follow and pending-input layout tests pass. Co-Authored-By: Claude Fable 5 --- apps/mobile/package.json | 2 +- ...3.3.patch => @legendapp__list@3.3.5.patch} | 184 +++++++++--------- pnpm-lock.yaml | 36 ++-- pnpm-workspace.yaml | 9 +- 4 files changed, 122 insertions(+), 109 deletions(-) rename patches/{@legendapp__list@3.3.3.patch => @legendapp__list@3.3.5.patch} (87%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8b6834c9714..39f5854ee2c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,7 +49,7 @@ "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/metro-runtime": "~56.0.15", "@expo/ui": "~56.0.18", - "@legendapp/list": "3.3.3", + "@legendapp/list": "3.3.5", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", diff --git a/patches/@legendapp__list@3.3.3.patch b/patches/@legendapp__list@3.3.5.patch similarity index 87% rename from patches/@legendapp__list@3.3.3.patch rename to patches/@legendapp__list@3.3.5.patch index 4fa135d5aa0..60a5954d8f2 100644 --- a/patches/@legendapp__list@3.3.3.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1,8 +1,8 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 7bc3bb8..75ec120 100644 +index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts -@@ -277,7 +277,7 @@ type KeyboardChatComposerInsetListRef = { +@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { type KeyboardChatComposerRef = { current: Pick | null; }; @@ -11,7 +11,7 @@ index 7bc3bb8..75ec120 100644 contentInsetEndAdjustment: SharedValue; onComposerLayout: (event: LayoutChangeEvent) => void; }; -@@ -286,8 +286,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb +@@ -288,8 +288,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise; }; declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { @@ -23,7 +23,7 @@ index 7bc3bb8..75ec120 100644 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 736286a..8218172 100644 +index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -62,8 +62,8 @@ index 736286a..8218172 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - includeInEndInset: true, +@@ -108,11 +110,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...anchoredEndSpace, onSizeChanged: (size) => { var _a; - blankSpace.value = size; @@ -80,7 +80,7 @@ index 736286a..8218172 100644 const onContentInsetChange = React.useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -123,6 +129,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( reactNativeKeyboardController.KeyboardChatScrollView, { ...scrollProps, @@ -88,7 +88,7 @@ index 736286a..8218172 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, extraContentPadding: contentInsetEndAdjustment, -@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -134,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -97,15 +97,15 @@ index 736286a..8218172 100644 blankSpace, contentInsetEndAdjustment, @@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, + anchoredEndSpaceOwnerInternal: "scroll", + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, ref: combinedRef, renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index c1dd270..cb0d142 100644 +index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { @@ -144,8 +144,8 @@ index c1dd270..cb0d142 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - includeInEndInset: true, +@@ -87,11 +89,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...anchoredEndSpace, onSizeChanged: (size) => { var _a; - blankSpace.value = size; @@ -162,7 +162,7 @@ index c1dd270..cb0d142 100644 const onContentInsetChange = useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -102,6 +108,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( KeyboardChatScrollView, { ...scrollProps, @@ -170,7 +170,7 @@ index c1dd270..cb0d142 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, extraContentPadding: contentInsetEndAdjustment, -@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -113,6 +120,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -179,15 +179,15 @@ index c1dd270..cb0d142 100644 blankSpace, contentInsetEndAdjustment, @@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, + anchoredEndSpaceOwnerInternal: "scroll", + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, ref: combinedRef, renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index 8204015..cdeaab7 100644 +index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -293,6 +293,12 @@ interface LegendListSpecificProps { @@ -204,10 +204,10 @@ index 8204015..cdeaab7 100644 * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index 229f09a..2a1ceb6 100644 +index b3c5a306b293f797a8b338adfca3060c0f6db22b..89077be4d6833cfaabf9d6d6205d9551505e32b4 100644 --- a/react-native.js +++ b/react-native.js -@@ -930,7 +930,7 @@ function setInitialRenderState(ctx, { +@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -216,7 +216,7 @@ index 229f09a..2a1ceb6 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1259,18 +1259,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1304,18 +1304,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -242,7 +242,7 @@ index 229f09a..2a1ceb6 100644 return clampedOffset; } -@@ -1406,10 +1411,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -255,19 +255,19 @@ index 229f09a..2a1ceb6 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1456,7 +1461,10 @@ function checkFinishedScrollFallback(ctx) { - }); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { -- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); -+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; -+ if (!isActivelyAnimatingToEnd) { -+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -+ } - scheduleFallbackCheck(100); - } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { - finishScrollTo(ctx); -@@ -1517,9 +1525,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1503,7 +1508,10 @@ function checkFinishedScrollFallback(ctx) { + ); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1566,9 +1574,18 @@ function doMaintainScrollAtEnd(ctx) { } if (shouldMaintainScrollAtEnd) { state.pendingMaintainScrollAtEnd = false; @@ -287,7 +287,7 @@ index 229f09a..2a1ceb6 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1539,9 +1556,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1591,9 +1608,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -309,7 +309,7 @@ index 229f09a..2a1ceb6 100644 } setTimeout( () => { -@@ -1571,6 +1597,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -320,7 +320,7 @@ index 229f09a..2a1ceb6 100644 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1674,7 +1704,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -331,7 +331,7 @@ index 229f09a..2a1ceb6 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1736,7 +1768,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1790,7 +1822,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -340,7 +340,7 @@ index 229f09a..2a1ceb6 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1869,7 +1901,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -349,7 +349,7 @@ index 229f09a..2a1ceb6 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2274,8 +2306,121 @@ function scrollToIndex(ctx, { +@@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -471,7 +471,7 @@ index 229f09a..2a1ceb6 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2704,7 +2849,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -482,9 +482,9 @@ index 229f09a..2a1ceb6 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4637,7 +4784,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { - } - contentBelowAnchor += footerSize + stylePaddingBottom; +@@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); + contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); @@ -492,20 +492,20 @@ index 229f09a..2a1ceb6 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4655,6 +4803,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateScroll(ctx, state.scroll, true); +@@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { + set$(ctx, "anchoredEndSpaceSize", nextSize); + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { -+ updateScroll(ctx, state.scroll, true); ++ updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); + } } return nextSize; } -@@ -6960,6 +7114,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7075,6 +7229,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -513,7 +513,7 @@ index 229f09a..2a1ceb6 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6990,6 +7145,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7105,6 +7260,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -521,7 +521,7 @@ index 229f09a..2a1ceb6 100644 onRefresh, onScroll: onScrollProp, onScrollBeginDrag, -@@ -7076,7 +7232,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7200,7 +7356,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -530,7 +530,7 @@ index 229f09a..2a1ceb6 100644 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7215,6 +7371,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7341,6 +7497,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -538,7 +538,7 @@ index 229f09a..2a1ceb6 100644 data: dataProp, dataKey, dataVersion, -@@ -7303,6 +7460,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7423,6 +7580,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -552,7 +552,7 @@ index 229f09a..2a1ceb6 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7526,6 +7690,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7651,6 +7815,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -560,7 +560,7 @@ index 229f09a..2a1ceb6 100644 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7555,6 +7720,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7681,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -569,10 +569,10 @@ index 229f09a..2a1ceb6 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index c2e0f38..5313086 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644 --- a/react-native.mjs +++ b/react-native.mjs -@@ -909,7 +909,7 @@ function setInitialRenderState(ctx, { +@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -581,7 +581,7 @@ index c2e0f38..5313086 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1238,18 +1238,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1283,18 +1283,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -607,7 +607,7 @@ index c2e0f38..5313086 100644 return clampedOffset; } -@@ -1385,10 +1390,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -620,19 +620,19 @@ index c2e0f38..5313086 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1435,7 +1440,10 @@ function checkFinishedScrollFallback(ctx) { - }); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { -- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); -+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; -+ if (!isActivelyAnimatingToEnd) { -+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -+ } - scheduleFallbackCheck(100); - } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { - finishScrollTo(ctx); -@@ -1496,9 +1504,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1482,7 +1487,10 @@ function checkFinishedScrollFallback(ctx) { + ); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1545,9 +1553,18 @@ function doMaintainScrollAtEnd(ctx) { } if (shouldMaintainScrollAtEnd) { state.pendingMaintainScrollAtEnd = false; @@ -652,7 +652,7 @@ index c2e0f38..5313086 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1518,9 +1535,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1570,9 +1587,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -674,7 +674,7 @@ index c2e0f38..5313086 100644 } setTimeout( () => { -@@ -1550,6 +1576,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -685,7 +685,7 @@ index c2e0f38..5313086 100644 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1653,7 +1683,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -696,7 +696,7 @@ index c2e0f38..5313086 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1715,7 +1747,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1769,7 +1801,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -705,7 +705,7 @@ index c2e0f38..5313086 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1848,7 +1880,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -714,7 +714,7 @@ index c2e0f38..5313086 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2253,8 +2285,121 @@ function scrollToIndex(ctx, { +@@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -836,7 +836,7 @@ index c2e0f38..5313086 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2683,7 +2828,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -847,9 +847,9 @@ index c2e0f38..5313086 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4616,7 +4763,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { - } - contentBelowAnchor += footerSize + stylePaddingBottom; +@@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); + contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); @@ -857,20 +857,20 @@ index c2e0f38..5313086 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4634,6 +4782,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateScroll(ctx, state.scroll, true); +@@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { + set$(ctx, "anchoredEndSpaceSize", nextSize); + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { -+ updateScroll(ctx, state.scroll, true); ++ updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); + } } return nextSize; } -@@ -6939,6 +7093,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7054,6 +7208,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -878,7 +878,7 @@ index c2e0f38..5313086 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7055,7 +7210,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7179,7 +7334,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -887,7 +887,7 @@ index c2e0f38..5313086 100644 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7194,6 +7349,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7320,6 +7475,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -895,7 +895,7 @@ index c2e0f38..5313086 100644 data: dataProp, dataKey, dataVersion, -@@ -7282,6 +7438,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7402,6 +7558,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -909,7 +909,7 @@ index c2e0f38..5313086 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7505,6 +7668,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7630,6 +7793,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -917,7 +917,7 @@ index c2e0f38..5313086 100644 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7534,6 +7698,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7660,6 +7824,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -926,7 +926,7 @@ index c2e0f38..5313086 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/reanimated.d.ts b/reanimated.d.ts -index 940da28..28dccbe 100644 +index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -294,6 +294,12 @@ interface LegendListSpecificProps { @@ -943,7 +943,7 @@ index 940da28..28dccbe 100644 * Number of columns to render items in. * @default 1 diff --git a/reanimated.js b/reanimated.js -index f1265fa..16dcef0 100644 +index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 100644 --- a/reanimated.js +++ b/reanimated.js @@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( @@ -978,7 +978,7 @@ index f1265fa..16dcef0 100644 style: viewStyle, ...rest diff --git a/reanimated.mjs b/reanimated.mjs -index 29a00d5..9c25ec5 100644 +index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 100644 --- a/reanimated.mjs +++ b/reanimated.mjs @@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2df3849992..8b3cf5e2df1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 - '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 + '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 @@ -212,8 +212,8 @@ importers: specifier: ~56.0.18 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) '@legendapp/list': - specifier: 3.3.3 - version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 3.3.5 + version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -539,7 +539,7 @@ importers: version: 0.9.0 '@legendapp/list': specifier: 3.3.3 - version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 3.3.3(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) @@ -2963,6 +2963,18 @@ packages: react-native: optional: true + '@legendapp/list@3.3.5': + resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==} + peerDependencies: + react: '*' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + '@lexical/clipboard@0.41.0': resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==} @@ -12988,7 +13000,14 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.3(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) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + + '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12996,13 +13015,6 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) - optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - '@lexical/clipboard@0.41.0': dependencies: '@lexical/html': 0.41.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3829850f51f..6b73a015ccd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -74,6 +74,7 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-beta.103" - alchemy@2.0.0-beta.65 - effect@4.0.0-beta.103 + - '@legendapp/list@3.3.5' overrides: "@clerk/backend": "catalog:" @@ -126,7 +127,7 @@ patchedDependencies: "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch - "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch + '@legendapp/list@3.3.5': patches/@legendapp__list@3.3.5.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch @@ -145,6 +146,6 @@ peerDependencyRules: vite: "*" supportedArchitectures: - cpu: [current, x64] - libc: [current, glibc] - os: [current, linux] + cpu: [ current, x64 ] + libc: [ current, glibc ] + os: [ current, linux ] From faa634c4c2bb235de5551261b01dbdef21bdad73 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 01:44:59 +0200 Subject: [PATCH 35/46] fix(mobile): snappier questionnaire toggle with deterministic end pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simulator-verified iteration on the questionnaire choreography: - One shared 170ms ease-out clock for the card's enter/exit and the feed inset glide, replacing the sluggish mixed 220ms timings. - The inset extra animates only upward (expanding); collapsing steps it down instantly, invisible behind the sinking card. - The list's own corrections for these inset changes drift on short content and compound across toggles (verified: transcript tail crept under the bar, worse after rapid cycles), so the end is re-pinned deterministically after each toggle settles whenever live-follow is engaged — a no-op when the anchor is already right. Verified on the iOS 26.5 simulator with a software keyboard against seeded short and long threads: collapse, expand, five rapid alternating cycles, collapse with the keyboard up, and custom-answer draft persistence all land with the transcript tail fully above the bar; the 80-message thread anchors at end, holds free-scroll position without drift, and re-engages follow at the end. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 12 ++++-- .../features/threads/ThreadDetailScreen.tsx | 38 +++++++++++++++++-- .../threads/pendingUserInputLayout.ts | 6 +++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 0f033335db7..690e3b7ecec 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,7 +1,9 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; import { useCallback, useRef } from "react"; import { Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; -import Animated, { FadeInUp, FadeOutDown, LinearTransition } from "react-native-reanimated"; +import Animated, { Easing, FadeInUp, FadeOutDown, LinearTransition } from "react-native-reanimated"; + +import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; @@ -124,8 +126,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { // the questions on top of whatever message happens to sit underneath. { - pendingCardInsetExtra.value = withTiming(userInputInsetExtraTarget, { duration: 220 }); - }, [pendingCardInsetExtra, userInputInsetExtraTarget]); - const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); + const expanding = userInputInsetExtraTarget > pendingCardInsetExtra.value; + if (expanding) { + // Expanding: glide the end of the chat up above the rising card. + pendingCardInsetExtra.value = withTiming(userInputInsetExtraTarget, { + duration: USER_INPUT_TOGGLE_DURATION_MS, + easing: Easing.out(Easing.cubic), + }); + } else { + // Collapsing: the sinking card still covers the strip being revealed, + // so an instant step is invisible behind it. + pendingCardInsetExtra.value = userInputInsetExtraTarget; + } + if (!endFollowEnabledRef.current) { + return; + } + // The list's own corrections for these inset changes drift on short + // content (and the error compounds across toggles), so deterministically + // re-pin the end once the change settles: a no-op when the resting + // position is already right, corrective when it is not. On collapse the + // correction lands while the sinking card still covers the strip. + const timer = setTimeout( + () => { + void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { + freeze.set(false); + }); + }, + expanding ? USER_INPUT_TOGGLE_DURATION_MS + 50 : 60, + ); + return () => clearTimeout(timer); + }, [freeze, pendingCardInsetExtra, scrollMessageToEnd, userInputInsetExtraTarget]); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts index e3ec3f2687e..7367455710b 100644 --- a/apps/mobile/src/features/threads/pendingUserInputLayout.ts +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -9,6 +9,12 @@ const PENDING_USER_INPUT_VERTICAL_GAP = 12; */ export const ESTIMATED_KEYBOARD_HEIGHT = 336; +/** + * One clock for the questionnaire expand/collapse choreography: the card's + * enter/exit and the feed-inset glide must share it or they visibly drift. + */ +export const USER_INPUT_TOGGLE_DURATION_MS = 170; + export function derivePendingUserInputMaxHeight(input: { readonly windowHeight: number; readonly keyboardHeight: number; From 755add8932120b7dda1ccdd23171b19e81716a9d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 12:15:26 +0200 Subject: [PATCH 36/46] fix(mobile): quarantine stale keyboard state after Android resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stable repro from the field: send a message (which blurs the composer and starts the IME hide), press Home about a second later, resume after a few seconds — the composer strands at the stale keyboard translation. Backgrounding mid keyboard-hide can swallow the WindowInsetsAnimation end callbacks, freezing the keyboard library's height AND visibility open, so the existing enabled={isKeyboardVisible} guard trusts exactly the state that went stale and does not cover this case. Quarantine the sticky translation on every Android resume instead; any evidence of a live keyboard stream — an owned input (composer editor or questionnaire custom answer) gaining focus, or any movement in the library's visibility or height — lifts it. A healthy resume sees no visual difference since the translation is already zero while the keyboard is closed. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 4 ++ .../src/features/threads/ThreadComposer.tsx | 9 +++- .../features/threads/ThreadDetailScreen.tsx | 44 ++++++++++++++++--- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 690e3b7ecec..0cb253e9030 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -34,6 +34,8 @@ export interface PendingUserInputCardProps { * keep the end of the chat visible above the card. */ readonly onCardCoverageChange?: (coverage: number) => void; + /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */ + readonly onInputFocusChange?: (focused: boolean) => void; readonly drafts: Record; readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; @@ -216,6 +218,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { value, ) } + onFocus={() => props.onInputFocusChange?.(true)} + onBlur={() => props.onInputFocusChange?.(false)} placeholder="Or type a custom answer" className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" /> diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index b68256ff833..6ce42aeb148 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -118,6 +118,8 @@ export interface ThreadComposerProps { readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; + /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ + readonly onEditorFocusChange?: (focused: boolean) => void; } /** @@ -312,13 +314,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } }, [inputRef]); + const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); - }, []); + onEditorFocusChange?.(true); + }, [onEditorFocusChange]); const handleBlur = useCallback(() => { setIsFocused(false); - }, []); + onEditorFocusChange?.(false); + }, [onEditorFocusChange]); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index f21b8281481..49b464f86eb 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -29,6 +29,7 @@ import { } from "react"; import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import { + AppState, Keyboard, Platform, useColorScheme, @@ -210,6 +211,38 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray state.isVisible); + const liveKeyboardHeight = useKeyboardState((state) => state.height); + // Android can swallow the IME hide callbacks when the app is backgrounded + // mid keyboard-hide (the reported repro: send — which blurs and starts the + // hide — then Home within a second). The keyboard library's height AND + // visibility then stay frozen open, so gating the sticky translation on + // visibility alone still strands the composer after resume. Quarantine the + // translation on every Android resume instead; any sign of a live keyboard + // stream — an owned input gaining focus, or any visibility/height movement — + // lifts it. A healthy resume sees no visual difference (the translation is + // already zero while the keyboard is closed). + const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); + useEffect(() => { + if (Platform.OS !== "android") { + return; + } + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") { + setKeyboardStateSuspect(true); + } + }); + return () => { + subscription.remove(); + }; + }, []); + useEffect(() => { + setKeyboardStateSuspect(false); + }, [isKeyboardVisible, liveKeyboardHeight]); + const handleOwnedInputFocusChange = useCallback((focused: boolean) => { + if (focused) { + setKeyboardStateSuspect(false); + } + }, []); const windowHeight = useWindowDimensions().height; const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; @@ -277,13 +310,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // stays compact over the transcript. Before the first open the reserve is // an estimate; once a real height is known the card corrects once, // discretely. - const measuredKeyboardHeight = useKeyboardState((state) => state.height); const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); useEffect(() => { - if (measuredKeyboardHeight > 0 && measuredKeyboardHeight !== lastKnownKeyboardHeight) { - setLastKnownKeyboardHeight(measuredKeyboardHeight); + if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { + setLastKnownKeyboardHeight(liveKeyboardHeight); } - }, [lastKnownKeyboardHeight, measuredKeyboardHeight]); + }, [lastKnownKeyboardHeight, liveKeyboardHeight]); const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ windowHeight, keyboardHeight: @@ -539,7 +571,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // The animated keyboard height can remain stale after a dismissed // IME on both platforms. Visibility is the authoritative closed // state, so disable the translation rather than stranding the pill. - enabled={isKeyboardVisible} + enabled={isKeyboardVisible && !keyboardStateSuspect} style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} offset={{ closed: 0, opened: 0 }} > @@ -619,6 +651,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onToggleCollapsed={handleToggleUserInputCollapsed} onStopThread={props.onStopThread} onCardCoverageChange={setUserInputCardCoverage} + onInputFocusChange={handleOwnedInputFocusChange} drafts={props.activePendingUserInputDrafts} answers={props.activePendingUserInputAnswers} respondingUserInputId={props.respondingUserInputId} @@ -662,6 +695,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} onExpandedChange={setComposerExpanded} + onEditorFocusChange={handleOwnedInputFocusChange} /> From ce5df72ed865fa2c13500e869d0331b8542da00f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 12:25:59 +0200 Subject: [PATCH 37/46] fix(mobile): in-flow questionnaire card on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android does not hit-test touches outside a parent's bounds, so the iOS overlay architecture (absolute card rising above the bar-sized wrapper) left everything above the wrapper untouchable — no scrolling, no option taps. Android now renders the expanded card in-flow (wrapper grows with it) and skips the iOS-only coverage inset since the measured overlay already includes the card; iOS keeps the overlay and its still-feed behavior. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 94 +++++++++++-------- .../features/threads/ThreadDetailScreen.tsx | 7 +- 2 files changed, 60 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 0cb253e9030..e3936cbab44 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,6 +1,6 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; import { useCallback, useRef } from "react"; -import { Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; +import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { Easing, FadeInUp, FadeOutDown, LinearTransition } from "react-native-reanimated"; import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; @@ -53,12 +53,20 @@ export interface PendingUserInputCardProps { } /** - * The collapsed bar is the PERMANENT in-flow footprint — the expanded card is - * an absolutely-positioned overlay rising above it. The overlay's measured - * height (which drives the thread feed's bottom inset) therefore never - * changes on collapse/expand, so the transcript stays perfectly still while - * the card animates over it. + * On iOS the collapsed bar is the PERMANENT in-flow footprint — the expanded + * card is an absolutely-positioned overlay rising above it. The overlay's + * measured height (which drives the thread feed's bottom inset) therefore + * never changes on collapse/expand, so the transcript stays perfectly still + * while the card animates over it. + * + * Android cannot use the overlay: it does not hit-test touches outside a + * parent's bounds, which made everything above the bar-sized wrapper + * untouchable. There the expanded card renders in-flow instead (the wrapper + * grows with it, and the host skips the coverage inset since the measured + * overlay already includes the card). */ +const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; + const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); export function PendingUserInputCard(props: PendingUserInputCardProps) { @@ -86,42 +94,45 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { [notifyCoverage], ); + const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; return ( - - - - User input needed - - - {questionCount} question{questionCount === 1 ? "" : "s"} - - - - - {props.onStopThread ? ( - - ) : null} - + + + User input needed + + + {questionCount} question{questionCount === 1 ? "" : "s"} + + + + + {props.onStopThread ? ( + + ) : null} + + ) : null} {props.collapsed ? null : ( // The surface is opaque on purpose: the card floats over the thread // feed with no blur behind it, so a translucent background renders @@ -135,7 +146,10 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { Easing.out(Easing.cubic), )} layout={CARD_LAYOUT_TRANSITION} - className="absolute inset-x-0 bottom-0 overflow-hidden gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900" + className={cn( + EXPANDED_CARD_IS_OVERLAY && "absolute inset-x-0 bottom-0", + "overflow-hidden gap-2.5 rounded-[20px] border border-neutral-200 bg-neutral-100 p-4 dark:border-white/6 dark:bg-neutral-900", + )} style={{ maxHeight: props.maxHeight }} > { From a12d5e66961a25561af71b5ffe5468ceee1a0465 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 12:32:20 +0200 Subject: [PATCH 38/46] fix(mobile): keep the Android new-thread composer above the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android branch positioned the composer with KeyboardAvoidingView's automaticOffset+padding, whose native viewPositionInWindow measurement is unreliable under this app's edge-to-edge setup (the keyboard provider neutralizes adjustResize while active, leaving that measurement as the sole compensation source) — the composer and its toolbar stayed under the keyboard. Switch the Android branch to the same KeyboardStickyView absolute-overlay pattern the thread screen uses; the working iOS branch is untouched. Co-Authored-By: Claude Fable 5 --- .../features/threads/NewTaskDraftScreen.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index bf2dfa8f4d4..1ece23ca055 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; -import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; +import { + KeyboardAvoidingView, + KeyboardStickyView, + useKeyboardState, +} from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; @@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: { // The draft is a thread that doesn't exist yet, so it mirrors the thread // page: in-screen header, empty feed canvas above, and the same floating // composer chrome as ThreadComposer (collapsed pill → expanded card). + // + // Composer positioning mirrors ThreadDetailScreen's floating overlay + // (KeyboardStickyView, absolute bottom overlay) rather than + // KeyboardAvoidingView's automaticOffset+padding: automaticOffset + // resolves the composer's on-screen frame via a native + // viewPositionInWindow measurement, which this app's Android + // edge-to-edge setup (KeyboardProvider's native content-view margin + // handling neutralizes windowSoftInputMode="adjustResize" while active) + // makes unreliable — the composer stayed under the keyboard instead of + // translating above it. KeyboardStickyView sticks directly to the + // animated keyboard height instead, sidestepping that measurement. return ( navigation.goBack()} /> - - + + ) : null} - + {settingsSheet} ); From 9e995aeca9e731c4fe9cdf5835ee4ed753ff69fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 13:26:18 +0200 Subject: [PATCH 39/46] chore: move @legendapp/list to the pnpm catalog at 3.3.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web was still on 3.3.3 after the mobile bump; both apps now consume the catalog entry so the version bumps as one. The shared install is the patched 3.3.5, which is invisible to web — it only imports @legendapp/list/react, and even /react-native resolves to the untouched react-native.web builds outside React Native. Web typecheck and the MessagesTimeline tests pass on 3.3.5. Co-Authored-By: Claude Fable 5 --- apps/mobile/package.json | 2 +- apps/web/package.json | 2 +- pnpm-lock.yaml | 35 +++++++++++++---------------------- pnpm-workspace.yaml | 11 ++++++----- 4 files changed, 21 insertions(+), 29 deletions(-) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 39f5854ee2c..de53a37c995 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,7 +49,7 @@ "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/metro-runtime": "~56.0.15", "@expo/ui": "~56.0.18", - "@legendapp/list": "3.3.5", + "@legendapp/list": "catalog:", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", diff --git a/apps/web/package.json b/apps/web/package.json index dfec330a107..68f6847b016 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.3.3", + "@legendapp/list": "catalog:", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b3cf5e2df1..b94ef3b67d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@effect/tsgo': specifier: 0.13.2 version: 0.13.2 + '@legendapp/list': + specifier: 3.3.5 + version: 3.3.5 '@noble/curves': specifier: 1.9.1 version: 1.9.1 @@ -212,7 +215,7 @@ importers: specifier: ~56.0.18 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) '@legendapp/list': - specifier: 3.3.5 + specifier: 'catalog:' version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' @@ -538,8 +541,8 @@ importers: specifier: ^0.9.0 version: 0.9.0 '@legendapp/list': - specifier: 3.3.3 - version: 3.3.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 'catalog:' + version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -2951,18 +2954,6 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} - '@legendapp/list@3.3.3': - resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} - peerDependencies: - react: '*' - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - '@legendapp/list@3.3.5': resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==} peerDependencies: @@ -13000,13 +12991,6 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.3(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) - optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 @@ -13015,6 +12999,13 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + '@lexical/clipboard@0.41.0': dependencies: '@lexical/html': 0.41.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b73a015ccd..f6b90756046 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,6 +39,7 @@ catalog: "@effect/sql-sqlite-bun": 4.0.0-beta.103 "@effect/tsgo": 0.13.2 "@effect/vitest": 4.0.0-beta.103 + "@legendapp/list": 3.3.5 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 "@pierre/diffs": 1.3.0-beta.10 @@ -74,7 +75,7 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-beta.103" - alchemy@2.0.0-beta.65 - effect@4.0.0-beta.103 - - '@legendapp/list@3.3.5' + - "@legendapp/list@3.3.5" overrides: "@clerk/backend": "catalog:" @@ -127,7 +128,7 @@ patchedDependencies: "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch - '@legendapp/list@3.3.5': patches/@legendapp__list@3.3.5.patch + "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch @@ -146,6 +147,6 @@ peerDependencyRules: vite: "*" supportedArchitectures: - cpu: [ current, x64 ] - libc: [ current, glibc ] - os: [ current, linux ] + cpu: [current, x64] + libc: [current, glibc] + os: [current, linux] From 12f2d97bfc139dd9d35f2c1b9dc7adff8316aca5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 13:38:43 +0200 Subject: [PATCH 40/46] fix(mobile): restore focus-keyed composer inset on iOS The merge port of #5988 keyed the composer's safe-area inset on keyboard visibility for both platforms. On iOS that flag only flips on keyboardDidHide, after the hide animation, so the composer rode down flush to the screen edge and then snapped up into the inset. Key it per platform: Android keeps visibility (#5988's back-gesture fix), iOS goes back to focus-keyed, where blur lands before the hide starts and the inset is already in place on the way down. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadDetailScreen.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 43e672eaf70..af74b78a8f5 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -256,11 +256,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); - // Key the safe-area padding on keyboard visibility, not focus: on Android - // the back gesture closes the keyboard while the editor stays focused, and - // a focus-keyed inset would leave the toolbar under the gesture bar. - // (Ported from main's #5988 during the merge.) - const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12); + // Android keys the safe-area padding on keyboard visibility (#5988): the + // back gesture closes the keyboard while the editor stays focused, and a + // focus-keyed inset would leave the toolbar under the gesture bar. iOS must + // NOT use visibility — it only flips on keyboardDidHide, after the hide + // animation, so the composer would ride down flush to the screen edge and + // then snap up into the inset. On iOS blur precedes the hide, so the + // focus-keyed inset is already in place while the composer rides down. + const composerBottomInset = (Platform.OS === "android" ? isKeyboardVisible : composerExpanded) + ? 0 + : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no From 17695d71db571948f4652f65429966d3390b2981 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 13:54:19 +0200 Subject: [PATCH 41/46] perf(mobile): shared-value questionnaire toggle on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expand felt laggy next to the keyboard because its start was JS-gated: tap → state → re-render → mount the five-question card → onLayout → coverage state → effect → only then did the feed inset start animating, with card and feed start times drifting under JS load. The card now stays mounted while collapsed on iOS (hidden via animated style, pointer events off) and the tap handler writes the progress shared values directly — card rise and feed glide start the same frame and run on the UI thread in lockstep, keyboard-style. Coverage is measured straight into a shared value from onLayout with no re-render, animated so arrival and discrete corrections glide. Also hardens the settle re-pin against the review-flagged race: follow state is re-checked inside the timer callback so a live gesture during the settle window is never overridden. Android keeps its mount-based in-flow behavior. Sim-verified: toggle correctness, touch passthrough while hidden, rapid-toggle stability, and keyboard interplay all pass. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 86 ++++++++-- .../features/threads/ThreadDetailScreen.tsx | 148 +++++++++++------- 2 files changed, 159 insertions(+), 75 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index e3936cbab44..0b1411b5576 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,7 +1,15 @@ import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; import { useCallback, useRef } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; -import Animated, { Easing, FadeInUp, FadeOutDown, LinearTransition } from "react-native-reanimated"; +import Animated, { + Easing, + FadeInUp, + FadeOutDown, + LinearTransition, + useAnimatedStyle, + withTiming, + type SharedValue, +} from "react-native-reanimated"; import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; @@ -29,11 +37,18 @@ export interface PendingUserInputCardProps { /** Renders a stop control on the collapsed bar, which replaces the composer. */ readonly onStopThread?: () => void; /** - * Reports how far the expanded card extends above the bar footprint, so the - * host can add that coverage to the thread feed's end inset (animated) and - * keep the end of the chat visible above the card. + * 0 collapsed → 1 expanded. Drives the iOS overlay card's opacity and + * rise on the UI thread; the host animates it directly from the tap + * handler so the card and the feed inset glide start the same frame. */ - readonly onCardCoverageChange?: (coverage: number) => void; + readonly cardProgress?: SharedValue; + /** + * Receives how far the expanded card extends above the bar footprint + * (written from onLayout with no re-render); the host adds it to the + * thread feed's end inset so the end of the chat stays visible above the + * card. + */ + readonly cardCoverage?: SharedValue; /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */ readonly onInputFocusChange?: (focused: boolean) => void; readonly drafts: Record; @@ -73,12 +88,25 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const iconSubtle = useThemeColor("--color-icon-subtle"); const questionCount = props.pendingUserInput.questions.length; - const onCardCoverageChange = props.onCardCoverageChange; + const cardCoverage = props.cardCoverage; const barHeightRef = useRef(0); const cardHeightRef = useRef(0); const notifyCoverage = useCallback(() => { - onCardCoverageChange?.(Math.max(0, cardHeightRef.current - barHeightRef.current)); - }, [onCardCoverageChange]); + if (!cardCoverage) { + return; + } + const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); + if (coverage === cardCoverage.value) { + return; + } + // Animated so a coverage change at rest (arrival measurement, discrete + // max-height corrections) glides the feed instead of stepping it; toggle + // timing is owned by the host's progress values. + cardCoverage.value = withTiming(coverage, { + duration: USER_INPUT_TOGGLE_DURATION_MS, + easing: Easing.out(Easing.cubic), + }); + }, [cardCoverage]); const handleBarLayout = useCallback( (event: LayoutChangeEvent) => { barHeightRef.current = event.nativeEvent.layout.height; @@ -93,7 +121,20 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { }, [notifyCoverage], ); + const cardProgress = props.cardProgress; + const cardAnimatedStyle = useAnimatedStyle(() => { + const progress = cardProgress === undefined ? 1 : cardProgress.value; + return { + opacity: progress, + transform: [{ translateY: (1 - progress) * 24 }], + }; + }); + // On iOS the card stays MOUNTED while collapsed (hidden via the animated + // style): expanding animates existing views on the UI thread the same + // frame the host starts the progress timing, instead of paying a React + // mount + layout before anything moves. + const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed; const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; return ( @@ -133,24 +174,35 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { ) : null} ) : null} - {props.collapsed ? null : ( + {renderCard ? ( // The surface is opaque on purpose: the card floats over the thread // feed with no blur behind it, so a translucent background renders // the questions on top of whatever message happens to sit underneath. Submit answers - )} + ) : null} ); } diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index af74b78a8f5..fa6c1e95040 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -208,6 +208,11 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray state.isVisible); @@ -297,19 +302,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; - const handleToggleUserInputCollapsed = useCallback(() => { - if (activeUserInputRequestId === null) { - return; - } - if (userInputCollapsed) { - setCollapsedUserInputRequestId(null); - } else { - // Collapsing hides the custom-answer inputs; release the keyboard with - // them instead of leaving it up over a dead responder. - Keyboard.dismiss(); - setCollapsedUserInputRequestId(activeUserInputRequestId); - } - }, [activeUserInputRequestId, userInputCollapsed]); // The card's height RESERVES keyboard space at all times instead of // tracking the keyboard: transforms (the sticky translation) apply // same-frame on the UI thread while layout props lag a Yoga pass behind, @@ -350,64 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), -nativeInsetOvercount, ); - // The expanded questionnaire is an absolute overlay, so it never changes - // the measured overlay height (that constancy is what keeps the feed from - // snapping on collapse/expand). Its coverage above the bar footprint is - // instead added to the feed's end inset HERE, animated in sync with the - // card's 220ms rise/sink, so the end of the chat glides up above the card - // the way it does for the keyboard. - const [userInputCardCoverage, setUserInputCardCoverage] = useState(0); - const pendingCardInsetExtra = useSharedValue(0); + // The expanded questionnaire is an absolute overlay on iOS, so it never + // changes the measured overlay height (that constancy is what keeps the + // feed from snapping on collapse/expand). The toggle choreography runs on + // SHARED VALUES set directly in the tap handler — one JS hop, then the + // card's rise/sink and the feed's end-inset glide animate in lockstep on + // the UI thread, keyboard-style, instead of waiting on React mount + + // onLayout + state round trips. Coverage (how far the card extends above + // the bar) is measured straight into a shared value by the card's + // onLayout, with no re-render. + const userInputCardProgress = useSharedValue(1); + const userInputInsetProgress = useSharedValue(1); + const userInputCardCoverage = useSharedValue(0); + // Android renders the expanded card in-flow (it cannot hit-test the iOS + // overlay outside the bar's bounds), so its measured overlay height already + // includes the card — the coverage extra is iOS-only. + const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null; const combinedContentInsetEndAdjustment = useSharedValue( Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), ); useAnimatedReaction( - () => contentInsetEndAdjustment.value + pendingCardInsetExtra.value, + () => + contentInsetEndAdjustment.value + + (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), (value) => { combinedContentInsetEndAdjustment.value = value; }, + [userInputCoverageApplies], ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); - // Android renders the expanded card in-flow (it cannot hit-test the iOS - // overlay outside the bar's bounds), so its measured overlay height already - // includes the card — the coverage extra is iOS-only. - const userInputInsetExtraTarget = - Platform.OS === "ios" && activeUserInputRequestId !== null && !userInputCollapsed - ? userInputCardCoverage - : 0; const endFollowEnabledRef = useRef(true); endFollowEnabledRef.current = endFollowEnabled; - useEffect(() => { - const expanding = userInputInsetExtraTarget > pendingCardInsetExtra.value; - if (expanding) { - // Expanding: glide the end of the chat up above the rising card. - pendingCardInsetExtra.value = withTiming(userInputInsetExtraTarget, { - duration: USER_INPUT_TOGGLE_DURATION_MS, - easing: Easing.out(Easing.cubic), - }); - } else { - // Collapsing: the sinking card still covers the strip being revealed, - // so an instant step is invisible behind it. - pendingCardInsetExtra.value = userInputInsetExtraTarget; - } - if (!endFollowEnabledRef.current) { - return; - } - // The list's own corrections for these inset changes drift on short - // content (and the error compounds across toggles), so deterministically - // re-pin the end once the change settles: a no-op when the resting - // position is already right, corrective when it is not. On collapse the - // correction lands while the sinking card still covers the strip. - const timer = setTimeout( - () => { + const userInputRepinTimerRef = useRef | null>(null); + // The list's own corrections for these inset changes drift on short + // content (and the error compounds across toggles), so deterministically + // re-pin the end once a toggle settles: a no-op when the resting position + // is already right, corrective when it is not. Follow state is re-checked + // inside the callback — the user may grab the list during the settle + // window, and yanking them back would override a live gesture. + const scheduleUserInputRepin = useCallback( + (delayMs: number) => { + if (userInputRepinTimerRef.current !== null) { + clearTimeout(userInputRepinTimerRef.current); + } + userInputRepinTimerRef.current = setTimeout(() => { + userInputRepinTimerRef.current = null; + if (!endFollowEnabledRef.current) { + return; + } void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { freeze.set(false); }); - }, - expanding ? USER_INPUT_TOGGLE_DURATION_MS + 50 : 60, - ); - return () => clearTimeout(timer); - }, [freeze, pendingCardInsetExtra, scrollMessageToEnd, userInputInsetExtraTarget]); + }, delayMs); + }, + [freeze, scrollMessageToEnd], + ); + useEffect( + () => () => { + if (userInputRepinTimerRef.current !== null) { + clearTimeout(userInputRepinTimerRef.current); + } + }, + [], + ); + const handleToggleUserInputCollapsed = useCallback(() => { + if (activeUserInputRequestId === null) { + return; + } + if (userInputCollapsed) { + // Expanding: card and feed glide start NOW, on the UI thread. + userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); + userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); + setCollapsedUserInputRequestId(null); + scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); + } else { + // Collapsing hides the custom-answer inputs; release the keyboard with + // them instead of leaving it up over a dead responder. + Keyboard.dismiss(); + userInputCardProgress.value = withTiming(0, USER_INPUT_TOGGLE_TIMING); + // Instant: the sinking card still covers the strip being revealed, and + // animating the inset downward is what drifted the short-content end + // anchor. + userInputInsetProgress.value = 0; + setCollapsedUserInputRequestId(activeUserInputRequestId); + scheduleUserInputRepin(60); + } + }, [ + activeUserInputRequestId, + scheduleUserInputRepin, + userInputCardProgress, + userInputCollapsed, + userInputInsetProgress, + ]); + useEffect(() => { + // A new request always arrives expanded. + userInputCardProgress.value = 1; + userInputInsetProgress.value = 1; + }, [activeUserInputRequestId, userInputCardProgress, userInputInsetProgress]); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; @@ -664,7 +695,8 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread collapsed={userInputCollapsed} onToggleCollapsed={handleToggleUserInputCollapsed} onStopThread={props.onStopThread} - onCardCoverageChange={setUserInputCardCoverage} + cardProgress={userInputCardProgress} + cardCoverage={userInputCardCoverage} onInputFocusChange={handleOwnedInputFocusChange} drafts={props.activePendingUserInputDrafts} answers={props.activePendingUserInputAnswers} From dcf7957d57ad8e80db05f84bdabe966a46d43a6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 14:05:44 +0200 Subject: [PATCH 42/46] fix(mobile): scope thread feed reset identity by environment Two environments can hold the same ThreadId, so keying the list mount and the per-thread reset effects on the bare id carried stale scroll/follow state across an environment switch. Co-Authored-By: Claude Fable 5 --- apps/mobile/src/features/threads/ThreadFeed.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 5f3c15ff5e3..0bd57799fe9 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { @@ -1550,9 +1551,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); + // Thread identity is env-scoped: two environments can hold the same + // ThreadId, and keying resets (or the list mount) on the bare id would + // carry stale scroll/follow state across an environment switch. + const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); + useEffect(() => { reportHeaderMaterialVisibility(false); - }, [props.threadId, reportHeaderMaterialVisibility]); + }, [feedThreadKey, reportHeaderMaterialVisibility]); // A thread switch opens pinned to the end; a send explicitly returns to the // live edge (ThreadDetailScreen scrolls the new message into place). Both @@ -1561,7 +1567,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { clearUserScrollSettle(); userScrollSessionRef.current = false; transitionEndFollow({ type: "reset" }); - }, [clearUserScrollSettle, props.threadId, transitionEndFollow]); + }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); useEffect(() => { if (props.anchorMessageId !== null) { clearUserScrollSettle(); @@ -1604,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // initial scroll-to-end computes with a zero end inset and rests one // composer-height short of the end. Layout effect: it must land before the // list's first positioning tick or the one-shot initial scroll misses it. - const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { From 17b38207c84ad98f72430839df0f336d77d30af4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 14:05:46 +0200 Subject: [PATCH 43/46] fix(mobile): harden RNS header item reuse cache and index guards The header/toolbar reuse caches now include the config instance in their keys: cached UIBarButtonItems capture the config's event emitter, so a remounted config with value-equal subviews must rebuild its items or presses dispatch to a dead emitter. Also guard the three insertObject:atIndex: sites against nil/negative indices. Co-Authored-By: Claude Fable 5 --- patches/react-native-screens@4.25.2.patch | 52 +++++++++++++++-------- pnpm-lock.yaml | 30 ++++++------- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch index 2bf3f3a8faf..ae75b0421eb 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.25.2.patch @@ -140,7 +140,7 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm -index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92d42ed9d9 100644 +index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbffcd7e3334 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm @@ -25,11 +25,33 @@ @@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV -@@ -637,10 +675,356 @@ + (void)updateViewController:(UIViewController *)vc +@@ -637,10 +675,364 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -239,7 +239,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 + NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; + NSArray *subviewLeftItems = navitem.leftBarButtonItems ?: @[]; + NSArray *subviewRightItems = navitem.rightBarButtonItems ?: @[]; -+ NSArray *headerItemsKey = @[ headerLeftConfigs, headerRightConfigs, headerCenterConfigs ]; ++ // The key includes the config instance's identity: cached items capture ++ // this config's event emitter in their press handlers, so a remounted ++ // header-config view with value-equal configs must still rebuild — reusing ++ // the old items would dispatch presses into the dead config's emitter. ++ NSArray *headerItemsKey = ++ @[ @((uintptr_t)config), headerLeftConfigs, headerRightConfigs, headerCenterConfigs ]; + // Rebuilding bar button items creates brand-new native buttons (glass + // UIButton custom views on iOS 26). Replacing them while UIKit animates an + // existing one (menu capsule morph, push/pop glass transitions) strands the @@ -564,12 +569,15 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 + } + + NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[]; -+ // Same reuse rule as the header item groups above. The top-view-controller -+ // and hidden-state checks scope the skip to same-screen refreshes, so -+ // transitions between screens with different toolbars still reapply. ++ // Same reuse rule as the header item groups above (including the config ++ // identity — cached toolbar items capture this config's event emitter). ++ // The top-view-controller and hidden-state checks scope the skip to ++ // same-screen refreshes, so transitions between screens with different ++ // toolbars still reapply. ++ NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ]; + BOOL reuseToolbarItems = navctr.topViewController == vc && + navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) && -+ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarConfigsKey]; ++ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey]; + if (!reuseToolbarItems) { + NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs + withCurrentItems:@[] @@ -582,12 +590,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 + [navctr setToolbarHidden:YES animated:animated]; + } + objc_setAssociatedObject( -+ navitem, &RNSAppliedToolbarConfigsKey, toolbarConfigsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); ++ navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). -@@ -773,6 +1157,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -773,6 +1165,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems @@ -595,7 +603,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 { if (dicts.count == 0) { return currentItems; -@@ -781,7 +1166,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -781,7 +1174,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObjectsFromArray:currentItems]; for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; @@ -783,7 +791,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 + } + } +#endif -+ if (index != nil && index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { + [items insertObject:item atIndex:index.integerValue]; + } else { + [items addObject:item]; @@ -794,7 +802,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( -@@ -809,11 +1384,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -803,19 +1386,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + } + imageLoader:_imageLoader]; + NSNumber *index = dict[@"index"]; +- if (index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { + [items insertObject:item atIndex:index.integerValue]; + } else { [items addObject:item]; } } else if (dict[@"spacing"]) { @@ -812,9 +827,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 + item.width = [spacingValue doubleValue]; + } NSNumber *index = dict[@"index"]; - if (index.integerValue < items.count) { +- if (index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { [items insertObject:item atIndex:index.integerValue]; -@@ -825,6 +1404,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + } else { + [items addObject:item]; +@@ -825,6 +1412,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } @@ -862,7 +880,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 RNS_IGNORE_SUPER_CALL_BEGIN - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex { -@@ -1013,6 +1633,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1013,6 +1641,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); @@ -871,7 +889,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); } -@@ -1038,6 +1660,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1038,6 +1668,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; @@ -879,7 +897,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..22efda48804f27b6f511cd7461c9dd92 if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; -@@ -1084,6 +1707,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1084,6 +1715,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b94ef3b67d6..c411ec0076d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,7 +86,7 @@ patchedDependencies: react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.25.2: 36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d + react-native-screens@4.25.2: 7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244 importers: @@ -237,7 +237,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(5be33aef4baeb633179867b7d83cc53c) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(251b4361f7d7581648fd5bf61c6ee344) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -402,7 +402,7 @@ importers: version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -12257,7 +12257,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(e85d238e900883f08c219be803dfa01b) + expo-router: 56.2.11(df9782d57ab3e719426aef3e83b65957) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12333,7 +12333,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(7c3ddb554a712b24b8b9902623b16c5e) + expo-router: 56.2.11(5dcbe4100ff3ee783bbbba0e1acca86e) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12673,7 +12673,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(e85d238e900883f08c219be803dfa01b) + expo-router: 56.2.11(df9782d57ab3e719426aef3e83b65957) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12688,7 +12688,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-router: 56.2.11(7c3ddb554a712b24b8b9902623b16c5e) + expo-router: 56.2.11(5dcbe4100ff3ee783bbbba0e1acca86e) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -14273,7 +14273,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(5be33aef4baeb633179867b7d83cc53c)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(251b4361f7d7581648fd5bf61c6ee344)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14281,7 +14281,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -17060,7 +17060,7 @@ snapshots: - supports-color - typescript - expo-router@56.2.11(7c3ddb554a712b24b8b9902623b16c5e): + expo-router@56.2.11(5dcbe4100ff3ee783bbbba0e1acca86e): dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) @@ -17091,7 +17091,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-screens: 4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-screens: 4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -17111,7 +17111,7 @@ snapshots: - supports-color optional: true - expo-router@56.2.11(e85d238e900883f08c219be803dfa01b): + expo-router@56.2.11(df9782d57ab3e719426aef3e83b65957): dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -17142,7 +17142,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -19957,14 +19957,14 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-screens@4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-screens@4.25.2(patch_hash=36ad36241f255c9859b23d48ac4b9b90d76c48248dc95a781af1978932967a6d)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-screens@4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: react: 19.2.6 react-freeze: 1.0.4(react@19.2.6) From b068fe579a29ecf6ca977aa3011503fe2d9922e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 15:10:05 +0200 Subject: [PATCH 44/46] fix(mobile): slide questionnaire collapse behind the bar, not crossfade Fading the opaque overlay card produced frames where card text, the transcript, and the collapsed bar were all half-visible at once. The card now stays opaque and slides its full height down through a clipping window whose bottom edge sits on the bar's bottom edge; the always-opaque bar renders under it and is revealed wipe-style by the card's top edge. No opacity animation remains in the toggle. The first coverage measurement is applied instantly (animating it from zero moved the end anchor out from under the initial end-pin on thread open), and the toggle clock is 220ms to match the longer travel. Co-Authored-By: Claude Fable 5 --- .../features/threads/PendingUserInputCard.tsx | 381 ++++++++++-------- .../threads/pendingUserInputLayout.ts | 4 +- 2 files changed, 208 insertions(+), 177 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 0b1411b5576..ddb625f9b21 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -7,6 +7,7 @@ import Animated, { FadeOutDown, LinearTransition, useAnimatedStyle, + useSharedValue, withTiming, type SharedValue, } from "react-native-reanimated"; @@ -37,9 +38,10 @@ export interface PendingUserInputCardProps { /** Renders a stop control on the collapsed bar, which replaces the composer. */ readonly onStopThread?: () => void; /** - * 0 collapsed → 1 expanded. Drives the iOS overlay card's opacity and - * rise on the UI thread; the host animates it directly from the tap - * handler so the card and the feed inset glide start the same frame. + * 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the + * collapsed bar (inside a clipping window) on the UI thread; the host + * animates it directly from the tap handler so the card and the feed + * inset glide start the same frame. */ readonly cardProgress?: SharedValue; /** @@ -91,6 +93,9 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const cardCoverage = props.cardCoverage; const barHeightRef = useRef(0); const cardHeightRef = useRef(0); + // Measured card height, written straight from onLayout: the collapse slide + // distance. Not animated — it only changes on discrete relayouts. + const cardHeight = useSharedValue(0); const notifyCoverage = useCallback(() => { if (!cardCoverage) { return; @@ -99,9 +104,16 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { if (coverage === cardCoverage.value) { return; } - // Animated so a coverage change at rest (arrival measurement, discrete - // max-height corrections) glides the feed instead of stepping it; toggle - // timing is owned by the host's progress values. + if (cardCoverage.value === 0) { + // First measurement lands while the list is doing its initial + // end-pin (thread opened onto a pending request); animating it from + // zero would move the end anchor out from under that scroll. + cardCoverage.value = coverage; + return; + } + // Animated so a coverage change at rest (discrete max-height + // corrections) glides the feed instead of stepping it; toggle timing is + // owned by the host's progress values. cardCoverage.value = withTiming(coverage, { duration: USER_INPUT_TOGGLE_DURATION_MS, easing: Easing.out(Easing.cubic), @@ -117,16 +129,21 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const handleCardLayout = useCallback( (event: LayoutChangeEvent) => { cardHeightRef.current = event.nativeEvent.layout.height; + cardHeight.value = event.nativeEvent.layout.height; notifyCoverage(); }, - [notifyCoverage], + [cardHeight, notifyCoverage], ); const cardProgress = props.cardProgress; + // No opacity: fading an opaque card over the live transcript reads as a + // crossfade (card text, transcript, and bar all half-visible at once). + // Instead the card stays opaque and slides its full height down past the + // clipping window's bottom edge, so the transcript is only revealed where + // the card has physically left. const cardAnimatedStyle = useAnimatedStyle(() => { const progress = cardProgress === undefined ? 1 : cardProgress.value; return { - opacity: progress, - transform: [{ translateY: (1 - progress) * 24 }], + transform: [{ translateY: (1 - progress) * cardHeight.value }], }; }); @@ -136,178 +153,190 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { // mount + layout before anything moves. const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed; const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; + // The bar renders UNDER the card (earlier in JSX), always opaque: while + // expanded the opaque card covers it, and during the collapse slide the + // card's top edge wipes past and reveals it — no opacity handoff, so no + // crossfade frames. + const bar = showBar ? ( + + + + User input needed + + + {questionCount} question{questionCount === 1 ? "" : "s"} + + + + + {props.onStopThread ? ( + + ) : null} + + ) : null; + const card = renderCard ? ( + // The surface is opaque on purpose: the card floats over the thread + // feed with no blur behind it, so a translucent background renders + // the questions on top of whatever message happens to sit underneath. + + + + + User input needed + + + Fill in the pending answers + + + + + + + + {props.pendingUserInput.questions.map((question) => { + const draft = props.drafts[question.id]; + return ( + + + {question.header} + + + {question.question} + + + {question.options.map((option) => { + const selected = isPendingUserInputOptionSelected(draft, option.label); + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question, + option.label, + ) + } + > + + {option.label} + + + ); + })} + + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + onFocus={() => props.onInputFocusChange?.(true)} + onBlur={() => props.onInputFocusChange?.(false)} + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + /> + + ); + })} + + void props.onSubmit()} + > + Submit answers + + + ) : null; return ( - {showBar ? ( + {bar} + {EXPANDED_CARD_IS_OVERLAY ? ( + // Clipping window for the collapse slide: same footprint as the + // expanded card, bottom edge on the bar's bottom edge. The sliding + // card exits through the bottom edge instead of drawing over the + // composer area, wiping the bar (and the transcript) into view. - - - User input needed - - - {questionCount} question{questionCount === 1 ? "" : "s"} - - - - - {props.onStopThread ? ( - - ) : null} + {card} - ) : null} - {renderCard ? ( - // The surface is opaque on purpose: the card floats over the thread - // feed with no blur behind it, so a translucent background renders - // the questions on top of whatever message happens to sit underneath. - - - - - User input needed - - - Fill in the pending answers - - - - - - - - {props.pendingUserInput.questions.map((question) => { - const draft = props.drafts[question.id]; - return ( - - - {question.header} - - - {question.question} - - - {question.options.map((option) => { - const selected = isPendingUserInputOptionSelected(draft, option.label); - return ( - - props.onSelectOption( - props.pendingUserInput.requestId, - question, - option.label, - ) - } - > - - {option.label} - - - ); - })} - - - props.onChangeCustomAnswer( - props.pendingUserInput.requestId, - question.id, - value, - ) - } - onFocus={() => props.onInputFocusChange?.(true)} - onBlur={() => props.onInputFocusChange?.(false)} - placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" - /> - - ); - })} - - void props.onSubmit()} - > - Submit answers - - - ) : null} + ) : ( + card + )} ); } diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts index 7367455710b..56924617e56 100644 --- a/apps/mobile/src/features/threads/pendingUserInputLayout.ts +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -12,8 +12,10 @@ export const ESTIMATED_KEYBOARD_HEIGHT = 336; /** * One clock for the questionnaire expand/collapse choreography: the card's * enter/exit and the feed-inset glide must share it or they visibly drift. + * Sized for the near-full-height slide (the card travels its own height), + * in the same class as the iOS keyboard's ~250ms. */ -export const USER_INPUT_TOGGLE_DURATION_MS = 170; +export const USER_INPUT_TOGGLE_DURATION_MS = 220; export function derivePendingUserInputMaxHeight(input: { readonly windowHeight: number; From 89675f29d2cf3997b1c7b71668f282821035072a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 15:57:24 +0200 Subject: [PATCH 45/46] fix(mobile): reuse mail-search toolbar across header updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateViewController tore the toolbar down and rebuilt it on every pass, so any unrelated header update while the fallback UISearchTextField was being edited replaced it with a fresh empty field — dropping the in-progress search text and dismissing the keyboard (the per-keystroke searchTextChange emit makes that loop easy to hit). Keep the live toolbar when the config instance, the toolbar config values, and the host width are unchanged. Co-Authored-By: Claude Fable 5 --- patches/react-native-screens@4.25.2.patch | 42 ++++++--- pnpm-lock.yaml | 106 +++++++++++----------- 2 files changed, 84 insertions(+), 64 deletions(-) diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch index ae75b0421eb..605366ff19a 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.25.2.patch @@ -140,7 +140,7 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm -index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbffcd7e3334 100644 +index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb2199dcbd58 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm @@ -25,11 +25,33 @@ @@ -226,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV -@@ -637,10 +675,364 @@ + (void)updateViewController:(UIViewController *)vc +@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -312,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff + if (existingMailSearchToolbar == nil) { + existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; + } -+ [existingMailSearchToolbar removeFromSuperview]; ++ // Reuse the live toolbar when nothing it was built from changed: rebuilding ++ // replaces the fallback UISearchTextField with a fresh empty one, dropping ++ // the user's in-progress search text and first responder on every unrelated ++ // header update. Keyed on the config instance (the button/search blocks ++ // capture its event emitter), the toolbar config values, and the host width ++ // (the width constraint constant is resolved from it at build time). ++ static char RNSAppliedMailSearchToolbarConfigKey; ++ NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil ++ ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ] ++ : nil; ++ BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil && ++ [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey) ++ isEqual:mailSearchToolbarKey]; ++ if (!reuseMailSearchToolbar) { ++ [existingMailSearchToolbar removeFromSuperview]; ++ } + -+ if (mailSearchToolbarConfig != nil) { ++ if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) { +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + if (@available(iOS 26.0, *)) { + CGFloat horizontalInset = 18.0; @@ -347,6 +362,11 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff + + UIView *toolbarHost = [[UIView alloc] init]; + toolbarHost.tag = RNSMailSearchToolbarViewTag; ++ objc_setAssociatedObject( ++ toolbarHost, ++ &RNSAppliedMailSearchToolbarConfigKey, ++ mailSearchToolbarKey, ++ OBJC_ASSOCIATION_RETAIN_NONATOMIC); + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; + [chromeHostView addSubview:toolbarHost]; + // The screen stays mounted beneath pushed routes, so its keyboard layout @@ -595,7 +615,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). -@@ -773,6 +1165,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems @@ -603,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff { if (dicts.count == 0) { return currentItems; -@@ -781,7 +1174,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObjectsFromArray:currentItems]; for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; @@ -802,7 +822,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( -@@ -803,19 +1386,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * } imageLoader:_imageLoader]; NSNumber *index = dict[@"index"]; @@ -832,7 +852,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff [items insertObject:item atIndex:index.integerValue]; } else { [items addObject:item]; -@@ -825,6 +1412,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } @@ -880,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff RNS_IGNORE_SUPER_CALL_BEGIN - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex { -@@ -1013,6 +1641,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); @@ -889,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); } -@@ -1038,6 +1668,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; @@ -897,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..d563871dd634e2d356810c0c55fcfbff if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; -@@ -1084,6 +1715,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c411ec0076d..8796302bab4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,7 +86,7 @@ patchedDependencies: react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.25.2: 7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244 + react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e importers: @@ -237,7 +237,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(251b4361f7d7581648fd5bf61c6ee344) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -402,7 +402,7 @@ importers: version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -12257,7 +12257,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(df9782d57ab3e719426aef3e83b65957) + expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12333,7 +12333,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(5dcbe4100ff3ee783bbbba0e1acca86e) + expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12673,7 +12673,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(df9782d57ab3e719426aef3e83b65957) + expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12688,7 +12688,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-router: 56.2.11(5dcbe4100ff3ee783bbbba0e1acca86e) + expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -14273,7 +14273,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(251b4361f7d7581648fd5bf61c6ee344)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14281,7 +14281,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -17060,47 +17060,47 @@ snapshots: - supports-color - typescript - expo-router@56.2.11(5dcbe4100ff3ee783bbbba0e1acca86e): + expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8): dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@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.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@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) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) - expo-constants: 56.0.18(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)) - expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-linking: 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.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo-constants: 56.0.18(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-glass-effect: 56.0.4(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-linking: 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-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-symbols: 56.0.6(expo-font@56.0.7)(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) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 query-string: 7.1.3 - react: 19.2.6 + react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-screens: 4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-dom: 19.2.3(react@19.2.3) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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.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)(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) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -17111,47 +17111,47 @@ snapshots: - supports-color optional: true - expo-router@56.2.11(df9782d57ab3e719426aef3e83b65957): + expo-router@56.2.11(db5c693a26481047569df6781f34db9f): dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) - '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@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) + '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@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.6)(utf-8-validate@6.0.6))(react@19.2.6) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(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-glass-effect: 56.0.4(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-linking: 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: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo-constants: 56.0.18(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)) + expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-linking: 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.6)(utf-8-validate@6.0.6))(react@19.2.6) expo-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(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-symbols: 56.0.6(expo-font@56.0.7)(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) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 query-string: 7.1.3 - react: 19.2.3 + react: 19.2.6 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 standard-navigation: 0.0.5 - vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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.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)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-dom: 19.2.6(react@19.2.6) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-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)(bufferutil@4.1.0)(utf-8-validate@6.0.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))(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) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -19957,14 +19957,14 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-screens@4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-screens@4.25.2(patch_hash=7b8cd981c340b176b3bce374a90e7dac04f90f3e61c5664f6e2c4f4abfda3244)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: react: 19.2.6 react-freeze: 1.0.4(react@19.2.6) From 401afe72b0a7c6035fcbb2df1768f95a399619bf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 16:00:53 +0200 Subject: [PATCH 46/46] Delete docs/user/threads.md --- docs/user/threads.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 docs/user/threads.md diff --git a/docs/user/threads.md b/docs/user/threads.md deleted file mode 100644 index 5d71264b5ed..00000000000 --- a/docs/user/threads.md +++ /dev/null @@ -1,5 +0,0 @@ -# Reading threads - -On mobile, scrolling away from the latest activity reveals a down-arrow button above the message -composer. Tap it to return to the end of the thread. The button disappears when the latest activity -is visible again.