authorgravatar for paul.trinko95@gmail.comPavlo Trinko <paul.trinko95@gmail.com> 2026-08-24 11:52:53+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 02:52:53-07:00
logf9a726e6231d76124e7210492745a40380c74df3
tree4724cd7707b900dbf2b2391dd8c5ba54937b0229
parent2d2efff28c4fc47745538570fcb7dd07272e3e4b
signature Signed by PGP key B5690EEEBB952194

fix(mobile): land the first thread open above the composer on Android (#5585)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

3 files changed, 60 insertions(+), 5 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+25-5
......@@ -76,7 +76,11 @@ import {
7676} from "../review/nativeReviewDiffAdapter";
7777import { buildReviewParsedDiff } from "../review/reviewModel";
7878import { cn } from "../../lib/cn";
79import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";
79import {
80 deriveCenteredContentHorizontalPadding,
81 deriveThreadFeedInitialContentInset,
82 type LayoutVariant,
83} from "../../lib/layout";
8084import {
8185 resolveMarkdownFontSizes,
8286 resolveNativeMarkdownTypography,
......@@ -1555,6 +1559,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
15551559 const bottomContentInset = props.contentBottomInset ?? 18;
15561560 const usesNativeAutomaticInsets =
15571561 props.usesAutomaticContentInsets === true && Platform.OS === "ios";
1562 const initialContentInset = deriveThreadFeedInitialContentInset({
1563 platform: Platform.OS,
1564 usesNativeAutomaticInsets,
1565 bottomContentInset,
1566 });
15581567 // With automatic insets the header inset lives in UIKit's adjustedContentInset,
15591568 // which LegendList's JS anchoring math cannot see — it measures the anchored
15601569 // end space from the scroll view's frame top. Fold the header height back into
......@@ -1794,10 +1803,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
17941803 // The empty↔filled key below remounts the list, which resets its imperative
17951804 // content-inset override — and useKeyboardChatComposerInset (mounted above
17961805 // the remount boundary) deduplicates by height, so it never re-reports the
1797 // composer inset to the fresh instance. Without this, the remounted list's
1798 // initial scroll-to-end computes with a zero end inset and rests one
1799 // composer-height short of the end. Layout effect: it must land before the
1800 // list's first positioning tick or the one-shot initial scroll misses it.
1806 // composer inset to the fresh instance. Re-report the measured overlay height
1807 // (composer plus any pending approval / user-input card) so the remounted
1808 // list's scroll math gets the true value; on Android the declarative
1809 // contentInset floor below covers the window before this effect lands.
18011810 const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;
18021811 useLayoutEffect(() => {
18031812 const bottom = props.contentInsetEndAdjustment.value;
......@@ -2104,6 +2113,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
21042113 // ThreadDetailScreen); this tells LegendList's scroll math about the
21052114 // extra so programmatic end scrolls land at the true resting offset.
21062115 contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}
2116 // Android: the composer overlay only exists as the keyboard
2117 // integration's animated bottom padding, which the list's scroll
2118 // math cannot see until the inset reports above land — and those
2119 // arrive via runOnJS, racing the remounted list's one-shot initial
2120 // scroll-at-end. Seed the estimated overlay height as a declarative
2121 // contentInset floor: LegendList consumes it in JS math only
2122 // (Android's ScrollView has no native contentInset prop) and the
2123 // first reported override REPLACES it instead of adding to it.
2124 // Not on iOS: there the prop would reach UIKit and inset natively
2125 // on top of the animated padding.
2126 {...(initialContentInset ? { contentInset: initialContentInset } : {})}
21072127 // The keyboard integration's offset math (end pinning, max scroll)
21082128 // must add the same UIKit-added extra, or its keyboard-open end
21092129 // targets land one safe-area short of the true resting offset.
apps/mobile/src/lib/layout.test.ts+23
......@@ -7,11 +7,34 @@ import {
77 deriveFileInspectorPaneLayout,
88 deriveLayout,
99 deriveStableFormSheetDetent,
10 deriveThreadFeedInitialContentInset,
1011 deriveWorkspacePaneLayout,
1112 SPLIT_LAYOUT_MIN_HEIGHT,
1213 SPLIT_LAYOUT_MIN_WIDTH,
1314} from "./layout";
1415
16describe("deriveThreadFeedInitialContentInset", () => {
17 it("seeds Android scroll math with the composer overlay estimate", () => {
18 expect(
19 deriveThreadFeedInitialContentInset({
20 platform: "android",
21 usesNativeAutomaticInsets: false,
22 bottomContentInset: 174,
23 }),
24 ).toEqual({ bottom: 174 });
25 });
26
27 it("does not double native iOS insets", () => {
28 expect(
29 deriveThreadFeedInitialContentInset({
30 platform: "ios",
31 usesNativeAutomaticInsets: true,
32 bottomContentInset: 174,
33 }),
34 ).toBeUndefined();
35 });
36});
37
1538describe("resizable pane constraints", () => {
1639 it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => {
1740 expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430);
apps/mobile/src/lib/layout.ts+12
......@@ -52,6 +52,18 @@ export interface FileInspectorPaneLayout {
5252 readonly width: number | null;
5353}
5454
55export function deriveThreadFeedInitialContentInset(input: {
56 readonly platform: string;
57 readonly usesNativeAutomaticInsets: boolean;
58 readonly bottomContentInset: number;
59}): { readonly bottom: number } | undefined {
60 if (input.platform !== "android" || input.usesNativeAutomaticInsets) {
61 return undefined;
62 }
63
64 return { bottom: Math.max(0, input.bottomContentInset) };
65}
66
5567export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";
5668
5769export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {