From adc45e69f4594aa4234b28600f12e7e61a10a99e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 15:46:59 -0800 Subject: [PATCH 1/2] fix(mobile): Android feed stays at its end after the keyboard or sidebar changes On Android the thread feed passed the composer height as a static contentInset. Legend List 3.7.2 ignores that prop off iOS, and its KeyboardAwareLegendList subtracts it from every padding report from KeyboardChatScrollView, so the list was told the composer padding was 0. Every end target (first open, keyboard settle, sidebar width hold) landed one composer height short of the native end. Drop the Android-only static inset. The list already seeds the composer inset when it attaches, so the first open still lands above the composer. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadFeed.tsx | 8 +------- apps/mobile/src/lib/layout.test.ts | 14 -------------- apps/mobile/src/lib/layout.ts | 12 ------------ 3 files changed, 1 insertion(+), 33 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 760663e639eeb..0658feaf392e3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -181,7 +181,6 @@ import { import { cn } from "../../lib/cn"; import { deriveCenteredContentHorizontalPadding, - deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, type LayoutVariant, } from "../../lib/layout"; @@ -2777,11 +2776,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const bottomContentInset = props.contentBottomInset ?? 18; const usesNativeAutomaticInsets = props.usesAutomaticContentInsets === true && Platform.OS === "ios"; - const initialContentInset = deriveThreadFeedInitialContentInset({ - platform: Platform.OS, - usesNativeAutomaticInsets, - bottomContentInset, - }); const anchorTopInset = usesNativeAutomaticInsets ? barInset : topContentInset; const theme = useUniwindTheme(); @@ -3671,7 +3665,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={props.contentInsetEndAdjustment} keyboardOffset={props.keyboardOffset} - contentInset={usesNativeAutomaticInsets ? { top: anchorTopInset } : initialContentInset} + {...(usesNativeAutomaticInsets ? { contentInset: { top: anchorTopInset } } : {})} freeze={props.freeze} maintainScrollAtEnd={jumpReady ? false : THREAD_FEED_MAINTAIN_SCROLL_AT_END} maintainVisibleContentPosition diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 3ec891ac8c4ca..0115a7b3334d3 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { alignListPaneToHinge, deriveLayout, - deriveThreadFeedInitialContentInset, deriveThreadArrangementSizing, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, @@ -50,19 +49,6 @@ describe("thread work-log text sizing", () => { }); }); -describe("deriveThreadFeedInitialContentInset", () => { - // regression: #5585 land the first thread open above the composer on Android - it("does not double native iOS insets", () => { - expect( - deriveThreadFeedInitialContentInset({ - platform: "ios", - usesNativeAutomaticInsets: true, - bottomContentInset: 174, - }), - ).toBeUndefined(); - }); -}); - describe("native workspace sizing", () => { it.each([ { width: 669, height: 951, hasHinge: true, nativeWorkspace: true, overlay: true }, diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index c255a03ceb3bd..bc019ed8eb3f7 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -93,18 +93,6 @@ export interface FileInspectorPaneLayout { readonly width: number | null; } -export function deriveThreadFeedInitialContentInset(input: { - readonly platform: string; - readonly usesNativeAutomaticInsets: boolean; - readonly bottomContentInset: number; -}): { readonly bottom: number } | undefined { - if (input.platform !== "android" || input.usesNativeAutomaticInsets) { - return undefined; - } - - return { bottom: Math.max(0, input.bottomContentInset) }; -} - export function deriveLayout(input: { readonly width: number; readonly height: number; From fd7e21188713901e8c575aa8836de876a5007742 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 16:27:40 -0800 Subject: [PATCH 2/2] fix(mobile): tapping Android message text to close the keyboard keeps the feed end A tap on selectable message text moves focus from the composer to that TextView, and Android's ScrollView scrolls a newly focused child into view. For a reply taller than the viewport that is a large native scroll with no drag, which Legend List treats as a user scroll, so it stops following the end before the keyboard inset shrinks. Taps on empty space take no focus and kept the end. Turn off scrollsChildToFocus on the feed. It is Android-only, so iOS is unchanged, and text selection still works. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadFeed.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 0658feaf392e3..e3e9e97e78510 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -3692,6 +3692,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { drawDistance={500} keyboardShouldPersistTaps="always" keyboardDismissMode="none" + // Android scrolls a tapped selectable message into view when it takes focus, which reads + // as a user scroll and drops end following before the keyboard closes. + scrollsChildToFocus={false} keyboardLiftBehavior={props.sendingWithKeyboard ? "persistent" : "whenAtEnd"} // Seed the initial viewport before native onLayout. {...(viewportHeight > 0 && viewportWidth > 0