authorgravatar for 30420087+gabrielelpidio@users.noreply.github.comGabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> 2026-07-29 21:21:16-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 21:21:16-04:00
log9e3e9bb4f081a6ad331cb94b42b2bd2d93339b81
tree7ea26f8951ec6368a90699fb265d08fa7010714b
parenta8e05cbb92633a1351529f2bc402071f615e5051
signature Signed by PGP key B5690EEEBB952194

fix(mobile): reduce thread feed scroll jank (#4874)


3 files changed, 169 insertions(+), 10 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+107-6
......@@ -48,7 +48,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
4848import Animated, {
4949 FadeIn,
5050 FadeInUp,
51 LinearTransition,
51 useSharedValue,
52 withTiming,
53 type LayoutAnimationsValues,
5254 type SharedValue,
5355} from "react-native-reanimated";
5456import { useThemeColor } from "../../lib/useThemeColor";
......@@ -80,7 +82,9 @@ import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../.
8082import {
8183 resolveMarkdownFontSizes,
8284 resolveNativeMarkdownTypography,
85 scaledTypographyLineHeight,
8386} from "../../lib/appearancePreferences";
87import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
8488import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
8589import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
8690import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
......@@ -91,7 +95,12 @@ import {
9195 type ThreadFeedLatestTurn,
9296} from "../../lib/threadActivity";
9397import type { ThreadContentPresentation } from "./threadContentPresentation";
94import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
98import {
99 collapsedWorkLogHeight,
100 ThreadWorkGroupToggle,
101 ThreadWorkLog,
102 WORK_GROUP_TOGGLE_HEIGHT,
103} from "./thread-work-log";
95104import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
96105import { useAssetUrl } from "../../state/assets";
97106import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
......@@ -109,8 +118,22 @@ function formatMessageTime(input: string): string {
109118}
110119
111120// Rows shift when content above them grows (streaming text, work-log folds);
112// animating the container position turns those jumps into slides.
113const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180);
121// animating the container position turns those jumps into slides. Applied
122// conditionally — see the gated transition in ThreadFeed: while browsing
123// history the animation must NOT run, or every estimate→actual size
124// correction plays as a visible slide against the instant scroll-offset
125// compensation from maintainVisibleContentPosition.
126const FEED_ITEM_LAYOUT_DURATION_MS = 180;
127
128// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's
129// classNames. The fold row's min-h-11 (44px) stays taller than its single
130// text-sm line at every supported base font size (26px at the 22pt maximum),
131// so its height is a constant; a drifted value costs one correction on
132// measure, not a persistent offset.
133const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
134// The working row has no min-height clamp — its height follows the scaled
135// text-xs line height (see workingRowHeight in ThreadFeed).
136const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16)
114137
115138// Entering animations must only play for rows born just now — LegendList
116139// remounts rows when they scroll back into view, and replaying an entrance for
......@@ -1301,6 +1324,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
13011324 const headerMaterialVisibleRef = useRef(false);
13021325 const previousLatestTurnRef = useRef(props.latestTurn);
13031326 const { width: windowWidth } = useWindowDimensions();
1327 const { appearance } = useAppearancePreferences();
13041328 const [viewportWidth, setViewportWidth] = useState(() =>
13051329 props.layoutVariant === "split" ? 0 : windowWidth,
13061330 );
......@@ -1410,6 +1434,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14101434 },
14111435 [props.onHeaderMaterialVisibilityChange],
14121436 );
1437 // True while the viewport sits within ~one screen of the list end — the
1438 // only region where layout shifts should animate. Starts true because the
1439 // list opens pinned to the end.
1440 const nearListEnd = useSharedValue(true);
1441
14131442 const handleScroll = useCallback(
14141443 (event: NativeSyntheticEvent<NativeScrollEvent>) => {
14151444 // anchorTopInset, not topContentInset: under automatic insets the list
......@@ -1417,9 +1446,40 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
14171446 // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
14181447 // header height back or the material toggles a full header too late.
14191448 reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
1449 const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
1450 nearListEnd.value =
1451 contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;
14201452 },
1421 [reportHeaderMaterialVisibility, anchorTopInset],
1453 [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd],
14221454 );
1455
1456 // Gated variant of the 180ms feed layout slide. Instant while browsing
1457 // history: maintainVisibleContentPosition compensates the scroll offset in
1458 // the same frame a row's measured size lands, so an instant reposition is
1459 // invisible — animating it is exactly what made cold upward scrolls slide
1460 // and jump. Near the end the slide stays on: streaming growth and sends
1461 // shift rows at rest, where the animation is the thing preventing a hard
1462 // visual snap.
1463 const feedItemLayoutTransition = useMemo(() => {
1464 return (values: LayoutAnimationsValues) => {
1465 "worklet";
1466 const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0;
1467 return {
1468 initialValues: {
1469 originX: values.currentOriginX,
1470 originY: values.currentOriginY,
1471 width: values.currentWidth,
1472 height: values.currentHeight,
1473 },
1474 animations: {
1475 originX: withTiming(values.targetOriginX, { duration }),
1476 originY: withTiming(values.targetOriginY, { duration }),
1477 width: withTiming(values.targetWidth, { duration }),
1478 height: withTiming(values.targetHeight, { duration }),
1479 },
1480 };
1481 };
1482 }, [nearListEnd]);
14231483 const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
14241484 const nextWidth = Math.round(event.nativeEvent.layout.width);
14251485 const nextHeight = Math.round(event.nativeEvent.layout.height);
......@@ -1636,6 +1696,38 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
16361696 setExpandedImage({ uri, headers });
16371697 }, []);
16381698
1699 // Rows whose height is known before they ever render. Without this, every
1700 // row above the viewport is assumed to be estimatedItemSize tall, and
1701 // scrolling up through unmeasured content corrects each row's height as it
1702 // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
1703 // exact; message rows stay undefined and use LegendList's per-type running
1704 // average once one of their type has been measured. Text-driven heights
1705 // follow the configurable base font size via scaledTypographyLineHeight.
1706 const workingRowHeight =
1707 WORKING_ROW_VERTICAL_EXTRAS +
1708 scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize);
1709 const getFixedItemSize = useCallback(
1710 (entry: ThreadFeedEntry) => {
1711 switch (entry.type) {
1712 case "turn-fold":
1713 return TURN_FOLD_HEIGHT;
1714 case "work-toggle":
1715 return WORK_GROUP_TOGGLE_HEIGHT;
1716 case "working":
1717 return workingRowHeight;
1718 case "activity-group":
1719 // Expanded rows append a variable detail block — fall back to
1720 // measurement for those groups.
1721 return entry.activities.some((activity) => expandedWorkRows[activity.id])
1722 ? undefined
1723 : collapsedWorkLogHeight(entry.activities, appearance.baseFontSize);
1724 default:
1725 return undefined;
1726 }
1727 },
1728 [expandedWorkRows, workingRowHeight, appearance.baseFontSize],
1729 );
1730
16391731 const renderItem = useCallback(
16401732 (info: { item: ThreadFeedEntry; index: number }) =>
16411733 renderFeedEntry(info, {
......@@ -1725,7 +1817,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
17251817 }
17261818 : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
17271819 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1728 itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION}
1820 itemLayoutAnimation={feedItemLayoutTransition}
17291821 // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
17301822 // lets its scroll math clamp programmatic scrolls to -headerInset
17311823 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
......@@ -1769,6 +1861,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
17691861 getItemType={(entry) =>
17701862 entry.type === "message" ? `message:${entry.message.role}` : entry.type
17711863 }
1864 getFixedItemSize={getFixedItemSize}
1865 // Measure rows well before they scroll into view so estimate→actual
1866 // corrections land offscreen instead of under the user's finger.
1867 drawDistance={500}
17721868 keyboardShouldPersistTaps="always"
17731869 keyboardDismissMode="none"
17741870 keyboardLiftBehavior="whenAtEnd"
......@@ -1788,6 +1884,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
17881884 // user overscroll back to the adjusted rest position.
17891885 scrollToOverflowEnabled
17901886 estimatedItemSize={180}
1887 // Chat-style bottom alignment: when a thread is shorter than the
1888 // viewport, pad above the content so messages rest just above the
1889 // composer instead of under the header. No effect on threads that
1890 // overflow the viewport (the padding clamps to zero).
1891 alignItemsAtEnd
17911892 initialScrollAtEnd
17921893 onScroll={handleScroll}
17931894 scrollEventThrottle={16}
apps/mobile/src/features/threads/thread-work-log.tsx+46-3
......@@ -3,8 +3,10 @@ import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
33import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";
44
55import { AppText as Text } from "../../components/AppText";
6import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
67import { cn } from "../../lib/cn";
78import type { ThreadFeedActivity } from "../../lib/threadActivity";
9import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
810import Animated, { FadeIn } from "react-native-reanimated";
911
1012const WORK_LOG_LAYOUT_ANIMATION = {
......@@ -77,6 +79,46 @@ function isFreshRow(createdAt: string): boolean {
7779 return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
7880}
7981
82// Tool-like activities with a neutral status carry no signal worth a row.
83export function visibleWorkLogActivities(
84 activities: ReadonlyArray<ThreadFeedActivity>,
85): ReadonlyArray<ThreadFeedActivity> {
86 return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral"));
87}
88
89// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log
90// rows are single-line (numberOfLines={1}) inside a min-height that stays
91// taller than the text at every supported base font size (text-xs reaches
92// 23px at the 22pt maximum, under the 32px min-h-8), so row height is
93// deterministic. The "work log" label has no such clamp — its height follows
94// the scaled text-2xs line height. Values mirror the classNames below — keep
95// them in sync; a mismatch only costs a one-time correction on measure.
96const WORK_ROW_HEIGHT = 32; // min-h-8
97const WORK_ROW_GAP = 1; // gap-px
98const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label
99const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1
100
101export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
102
103export function collapsedWorkLogHeight(
104 activities: ReadonlyArray<ThreadFeedActivity>,
105 baseFontSize: number,
106): number {
107 const rows = visibleWorkLogActivities(activities);
108 if (rows.length === 0) {
109 return 0;
110 }
111 const onlyToolRows = rows.every((row) => row.toolLike);
112 const headerHeight =
113 scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING;
114 return (
115 WORK_LOG_BOTTOM_MARGIN +
116 (onlyToolRows ? 0 : headerHeight) +
117 rows.length * WORK_ROW_HEIGHT +
118 (rows.length - 1) * WORK_ROW_GAP
119 );
120}
121
80122export function ThreadWorkLog(props: {
81123 readonly activities: ReadonlyArray<ThreadFeedActivity>;
82124 readonly copiedRowId: string | null;
......@@ -87,9 +129,10 @@ export function ThreadWorkLog(props: {
87129}) {
88130 const colorScheme = useColorScheme();
89131 const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
90 const rows = props.activities
91 .filter((activity) => !(activity.toolLike && activity.status === "neutral"))
92 .map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) }));
132 const rows = visibleWorkLogActivities(props.activities).map((activity) => ({
133 ...activity,
134 detail: compactActivityDetail(activity.detail),
135 }));
93136
94137 if (rows.length === 0) {
95138 return null;
apps/mobile/src/lib/appearancePreferences.ts+16-1
......@@ -198,12 +198,27 @@ export function resolveTextScaleVariables(baseFontSize: number): Record<string,
198198
199199 for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) {
200200 variables[name] = Math.max(8, Math.round(role.fontSize * scale));
201 variables[`${name}--line-height`] = Math.max(10, Math.round(role.lineHeight * scale));
201 variables[`${name}--line-height`] = scaledTypographyLineHeight(role, baseFontSize);
202202 }
203203
204204 return variables;
205205}
206206
207/**
208 * The line height a MOBILE_TYPOGRAPHY role renders at under the given base
209 * font size — the same value resolveTextScaleVariables injects for the role's
210 * `--text-*--line-height` variable. For layout code that must predict
211 * text-driven heights (e.g. the thread feed's fixed item sizes) instead of
212 * measuring them.
213 */
214export function scaledTypographyLineHeight(
215 role: { readonly lineHeight: number },
216 baseFontSize: number,
217): number {
218 const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
219 return Math.max(10, Math.round(role.lineHeight * scale));
220}
221
207222export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {
208223 const fontSizes = resolveMarkdownFontSizes(baseFontSize);
209224 return {