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 {...@@ -76,7 +76,11 @@ import {
76} from "../review/nativeReviewDiffAdapter";76} from "../review/nativeReviewDiffAdapter";
77import { buildReviewParsedDiff } from "../review/reviewModel";77import { buildReviewParsedDiff } from "../review/reviewModel";
78import { cn } from "../../lib/cn";78import { cn } from "../../lib/cn";
79import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../../lib/layout";79import {
80 deriveCenteredContentHorizontalPadding,
81 deriveThreadFeedInitialContentInset,
82 type LayoutVariant,
83} from "../../lib/layout";
80import {84import {
81 resolveMarkdownFontSizes,85 resolveMarkdownFontSizes,
82 resolveNativeMarkdownTypography,86 resolveNativeMarkdownTypography,
...@@ -1555,6 +1559,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1555,6 +1559,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1555 const bottomContentInset = props.contentBottomInset ?? 18;1559 const bottomContentInset = props.contentBottomInset ?? 18;
1556 const usesNativeAutomaticInsets =1560 const usesNativeAutomaticInsets =
1557 props.usesAutomaticContentInsets === true && Platform.OS === "ios";1561 props.usesAutomaticContentInsets === true && Platform.OS === "ios";
1562 const initialContentInset = deriveThreadFeedInitialContentInset({
1563 platform: Platform.OS,
1564 usesNativeAutomaticInsets,
1565 bottomContentInset,
1566 });
1558 // With automatic insets the header inset lives in UIKit's adjustedContentInset,1567 // With automatic insets the header inset lives in UIKit's adjustedContentInset,
1559 // which LegendList's JS anchoring math cannot see — it measures the anchored1568 // which LegendList's JS anchoring math cannot see — it measures the anchored
1560 // end space from the scroll view's frame top. Fold the header height back into1569 // 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) {...@@ -1794,10 +1803,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1794 // The empty↔filled key below remounts the list, which resets its imperative1803 // The empty↔filled key below remounts the list, which resets its imperative
1795 // content-inset override — and useKeyboardChatComposerInset (mounted above1804 // content-inset override — and useKeyboardChatComposerInset (mounted above
1796 // the remount boundary) deduplicates by height, so it never re-reports the1805 // the remount boundary) deduplicates by height, so it never re-reports the
1797 // composer inset to the fresh instance. Without this, the remounted list's1806 // composer inset to the fresh instance. Re-report the measured overlay height
1798 // initial scroll-to-end computes with a zero end inset and rests one1807 // (composer plus any pending approval / user-input card) so the remounted
1799 // composer-height short of the end. Layout effect: it must land before the1808 // list's scroll math gets the true value; on Android the declarative
1800 // list's first positioning tick or the one-shot initial scroll misses it.1809 // contentInset floor below covers the window before this effect lands.
1801 const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;1810 const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`;
1802 useLayoutEffect(() => {1811 useLayoutEffect(() => {
1803 const bottom = props.contentInsetEndAdjustment.value;1812 const bottom = props.contentInsetEndAdjustment.value;
...@@ -2104,6 +2113,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -2104,6 +2113,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
2104 // ThreadDetailScreen); this tells LegendList's scroll math about the2113 // ThreadDetailScreen); this tells LegendList's scroll math about the
2105 // extra so programmatic end scrolls land at the true resting offset.2114 // extra so programmatic end scrolls land at the true resting offset.
2106 contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0}2115 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 } : {})}
2107 // The keyboard integration's offset math (end pinning, max scroll)2127 // The keyboard integration's offset math (end pinning, max scroll)
2108 // must add the same UIKit-added extra, or its keyboard-open end2128 // must add the same UIKit-added extra, or its keyboard-open end
2109 // targets land one safe-area short of the true resting offset.2129 // targets land one safe-area short of the true resting offset.
apps/mobile/src/lib/layout.test.ts+23
...@@ -7,11 +7,34 @@ import {...@@ -7,11 +7,34 @@ import {
7 deriveFileInspectorPaneLayout,7 deriveFileInspectorPaneLayout,
8 deriveLayout,8 deriveLayout,
9 deriveStableFormSheetDetent,9 deriveStableFormSheetDetent,
10 deriveThreadFeedInitialContentInset,
10 deriveWorkspacePaneLayout,11 deriveWorkspacePaneLayout,
11 SPLIT_LAYOUT_MIN_HEIGHT,12 SPLIT_LAYOUT_MIN_HEIGHT,
12 SPLIT_LAYOUT_MIN_WIDTH,13 SPLIT_LAYOUT_MIN_WIDTH,
13} from "./layout";14} 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
15describe("resizable pane constraints", () => {38describe("resizable pane constraints", () => {
16 it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => {39 it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => {
17 expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430);40 expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430);
apps/mobile/src/lib/layout.ts+12
...@@ -52,6 +52,18 @@ export interface FileInspectorPaneLayout {...@@ -52,6 +52,18 @@ export interface FileInspectorPaneLayout {
52 readonly width: number | null;52 readonly width: number | null;
53}53}
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
55export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";67export type WorkspaceAuxiliaryPaneRole = "supplementary" | "inspector";
5668
57export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {69export function deriveLayout(input: { readonly width: number; readonly height: number }): Layout {