From d39ce79a8b81b2c900132230aa1072653c1f34d2 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:41:14 -0400 Subject: [PATCH 1/2] fix(mobile): reduce thread feed scroll jank - Disable layout slides while browsing history - Add fixed row sizing and bottom alignment for smoother scrolling --- .../src/features/threads/ThreadFeed.tsx | 103 +++++++++++++++++- .../src/features/threads/thread-work-log.tsx | 39 ++++++- 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 37a8639fdbd..46065fd6c83 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -48,7 +48,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, - LinearTransition, + useSharedValue, + withTiming, + type LayoutAnimationsValues, type SharedValue, } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -91,7 +93,12 @@ import { type ThreadFeedLatestTurn, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; -import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log"; +import { + collapsedWorkLogHeight, + ThreadWorkGroupToggle, + ThreadWorkLog, + WORK_GROUP_TOGGLE_HEIGHT, +} from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { useAssetUrl } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; @@ -109,8 +116,19 @@ function formatMessageTime(input: string): string { } // Rows shift when content above them grows (streaming text, work-log folds); -// animating the container position turns those jumps into slides. -const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180); +// animating the container position turns those jumps into slides. Applied +// conditionally — see the gated transition in ThreadFeed: while browsing +// history the animation must NOT run, or every estimate→actual size +// correction plays as a visible slide against the instant scroll-offset +// compensation from maintainVisibleContentPosition. +const FEED_ITEM_LAYOUT_DURATION_MS = 180; + +// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's +// classNames. Both rows hold a single text line shorter than the fixed parts, +// so the height is deterministic; a drifted value costs one correction on +// measure, not a persistent offset. +const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12) +const WORKING_ROW_HEIGHT = 40; // py-1 (8) + text-xs line (16) + mb-4 (16) // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for @@ -1410,6 +1428,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { }, [props.onHeaderMaterialVisibilityChange], ); + // True while the viewport sits within ~one screen of the list end — the + // only region where layout shifts should animate. Starts true because the + // list opens pinned to the end. + const nearListEnd = useSharedValue(true); + const handleScroll = useCallback( (event: NativeSyntheticEvent) => { // anchorTopInset, not topContentInset: under automatic insets the list @@ -1417,9 +1440,40 @@ 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); + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + nearListEnd.value = + contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; }, - [reportHeaderMaterialVisibility, anchorTopInset], + [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd], ); + + // Gated variant of the 180ms feed layout slide. Instant while browsing + // history: maintainVisibleContentPosition compensates the scroll offset in + // the same frame a row's measured size lands, so an instant reposition is + // invisible — animating it is exactly what made cold upward scrolls slide + // and jump. Near the end the slide stays on: streaming growth and sends + // shift rows at rest, where the animation is the thing preventing a hard + // visual snap. + const feedItemLayoutTransition = useMemo(() => { + return (values: LayoutAnimationsValues) => { + "worklet"; + const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0; + return { + initialValues: { + originX: values.currentOriginX, + originY: values.currentOriginY, + width: values.currentWidth, + height: values.currentHeight, + }, + animations: { + originX: withTiming(values.targetOriginX, { duration }), + originY: withTiming(values.targetOriginY, { duration }), + width: withTiming(values.targetWidth, { duration }), + height: withTiming(values.targetHeight, { duration }), + }, + }; + }; + }, [nearListEnd]); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); const nextHeight = Math.round(event.nativeEvent.layout.height); @@ -1636,6 +1690,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setExpandedImage({ uri, headers }); }, []); + // Rows whose height is known before they ever render. Without this, every + // row above the viewport is assumed to be estimatedItemSize tall, and + // scrolling up through unmeasured content corrects each row's height as it + // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows + // exact; message rows stay undefined and use LegendList's per-type running + // average once one of their type has been measured. + const getFixedItemSize = useCallback( + (entry: ThreadFeedEntry) => { + switch (entry.type) { + case "turn-fold": + return TURN_FOLD_HEIGHT; + case "work-toggle": + return WORK_GROUP_TOGGLE_HEIGHT; + case "working": + return WORKING_ROW_HEIGHT; + case "activity-group": + // Expanded rows append a variable detail block — fall back to + // measurement for those groups. + return entry.activities.some((activity) => expandedWorkRows[activity.id]) + ? undefined + : collapsedWorkLogHeight(entry.activities); + default: + return undefined; + } + }, + [expandedWorkRows], + ); + const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => renderFeedEntry(info, { @@ -1725,7 +1807,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION} + itemLayoutAnimation={feedItemLayoutTransition} // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): // lets its scroll math clamp programmatic scrolls to -headerInset // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short @@ -1769,6 +1851,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { getItemType={(entry) => entry.type === "message" ? `message:${entry.message.role}` : entry.type } + getFixedItemSize={getFixedItemSize} + // Measure rows well before they scroll into view so estimate→actual + // corrections land offscreen instead of under the user's finger. + drawDistance={500} keyboardShouldPersistTaps="always" keyboardDismissMode="none" keyboardLiftBehavior="whenAtEnd" @@ -1788,6 +1874,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // user overscroll back to the adjusted rest position. scrollToOverflowEnabled estimatedItemSize={180} + // Chat-style bottom alignment: when a thread is shorter than the + // viewport, pad above the content so messages rest just above the + // composer instead of under the header. No effect on threads that + // overflow the viewport (the padding clamps to zero). + alignItemsAtEnd initialScrollAtEnd onScroll={handleScroll} scrollEventThrottle={16} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 0896335ea96..79f8308984e 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -77,6 +77,38 @@ function isFreshRow(createdAt: string): boolean { return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; } +// Tool-like activities with a neutral status carry no signal worth a row. +export function visibleWorkLogActivities( + activities: ReadonlyArray, +): ReadonlyArray { + return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral")); +} + +// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log +// rows are single-line (numberOfLines={1}) inside a min-height taller than the +// text, so their height is deterministic. Values mirror the classNames below — +// keep them in sync; a mismatch only costs a one-time correction on measure. +const WORK_ROW_HEIGHT = 32; // min-h-8 +const WORK_ROW_GAP = 1; // gap-px +const WORK_LOG_HEADER_HEIGHT = 18; // "work log" label: text-2xs line (16) + pb-0.5 (2) +const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 + +export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) + +export function collapsedWorkLogHeight(activities: ReadonlyArray): number { + const rows = visibleWorkLogActivities(activities); + if (rows.length === 0) { + return 0; + } + const onlyToolRows = rows.every((row) => row.toolLike); + return ( + WORK_LOG_BOTTOM_MARGIN + + (onlyToolRows ? 0 : WORK_LOG_HEADER_HEIGHT) + + rows.length * WORK_ROW_HEIGHT + + (rows.length - 1) * WORK_ROW_GAP + ); +} + export function ThreadWorkLog(props: { readonly activities: ReadonlyArray; readonly copiedRowId: string | null; @@ -87,9 +119,10 @@ export function ThreadWorkLog(props: { }) { const colorScheme = useColorScheme(); const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)"; - const rows = props.activities - .filter((activity) => !(activity.toolLike && activity.status === "neutral")) - .map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) })); + const rows = visibleWorkLogActivities(props.activities).map((activity) => ({ + ...activity, + detail: compactActivityDetail(activity.detail), + })); if (rows.length === 0) { return null; From ca5441e0c860187a10692e5fb71b2fe504449c3a Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:56:26 -0400 Subject: [PATCH 2/2] fix(mobile): derive fixed feed row heights from the configurable text scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The working row and work-log header heights baked in default-scale line heights; at larger base font sizes the fixed sizes went stale (review feedback on #4874). Predict them with scaledTypographyLineHeight — the same formula resolveTextScaleVariables injects into Uniwind — so the predicted and rendered heights cannot drift. Co-Authored-By: Claude Fable 5 --- .../src/features/threads/ThreadFeed.tsx | 24 +++++++++++++------ .../src/features/threads/thread-work-log.tsx | 22 ++++++++++++----- apps/mobile/src/lib/appearancePreferences.ts | 17 ++++++++++++- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 46065fd6c83..8ad117c8635 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -82,7 +82,9 @@ import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../. import { resolveMarkdownFontSizes, resolveNativeMarkdownTypography, + scaledTypographyLineHeight, } from "../../lib/appearancePreferences"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; @@ -124,11 +126,14 @@ function formatMessageTime(input: string): string { const FEED_ITEM_LAYOUT_DURATION_MS = 180; // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's -// classNames. Both rows hold a single text line shorter than the fixed parts, -// so the height is deterministic; a drifted value costs one correction on +// classNames. The fold row's min-h-11 (44px) stays taller than its single +// text-sm line at every supported base font size (26px at the 22pt maximum), +// so its height is a constant; a drifted value costs one correction on // measure, not a persistent offset. const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12) -const WORKING_ROW_HEIGHT = 40; // py-1 (8) + text-xs line (16) + mb-4 (16) +// The working row has no min-height clamp — its height follows the scaled +// text-xs line height (see workingRowHeight in ThreadFeed). +const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16) // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for @@ -1319,6 +1324,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const { width: windowWidth } = useWindowDimensions(); + const { appearance } = useAppearancePreferences(); const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); @@ -1695,7 +1701,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // scrolling up through unmeasured content corrects each row's height as it // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows // exact; message rows stay undefined and use LegendList's per-type running - // average once one of their type has been measured. + // average once one of their type has been measured. Text-driven heights + // follow the configurable base font size via scaledTypographyLineHeight. + const workingRowHeight = + WORKING_ROW_VERTICAL_EXTRAS + + scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize); const getFixedItemSize = useCallback( (entry: ThreadFeedEntry) => { switch (entry.type) { @@ -1704,18 +1714,18 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "work-toggle": return WORK_GROUP_TOGGLE_HEIGHT; case "working": - return WORKING_ROW_HEIGHT; + return workingRowHeight; case "activity-group": // Expanded rows append a variable detail block — fall back to // measurement for those groups. return entry.activities.some((activity) => expandedWorkRows[activity.id]) ? undefined - : collapsedWorkLogHeight(entry.activities); + : collapsedWorkLogHeight(entry.activities, appearance.baseFontSize); default: return undefined; } }, - [expandedWorkRows], + [expandedWorkRows, workingRowHeight, appearance.baseFontSize], ); const renderItem = useCallback( diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 79f8308984e..529adac1db3 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -3,8 +3,10 @@ import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; +import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; import Animated, { FadeIn } from "react-native-reanimated"; const WORK_LOG_LAYOUT_ANIMATION = { @@ -85,25 +87,33 @@ export function visibleWorkLogActivities( } // Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log -// rows are single-line (numberOfLines={1}) inside a min-height taller than the -// text, so their height is deterministic. Values mirror the classNames below — -// keep them in sync; a mismatch only costs a one-time correction on measure. +// rows are single-line (numberOfLines={1}) inside a min-height that stays +// taller than the text at every supported base font size (text-xs reaches +// 23px at the 22pt maximum, under the 32px min-h-8), so row height is +// deterministic. The "work log" label has no such clamp — its height follows +// the scaled text-2xs line height. Values mirror the classNames below — keep +// them in sync; a mismatch only costs a one-time correction on measure. const WORK_ROW_HEIGHT = 32; // min-h-8 const WORK_ROW_GAP = 1; // gap-px -const WORK_LOG_HEADER_HEIGHT = 18; // "work log" label: text-2xs line (16) + pb-0.5 (2) +const WORK_LOG_HEADER_PADDING = 2; // pb-0.5 under the "work log" label const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1 export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4) -export function collapsedWorkLogHeight(activities: ReadonlyArray): number { +export function collapsedWorkLogHeight( + activities: ReadonlyArray, + baseFontSize: number, +): number { const rows = visibleWorkLogActivities(activities); if (rows.length === 0) { return 0; } const onlyToolRows = rows.every((row) => row.toolLike); + const headerHeight = + scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING; return ( WORK_LOG_BOTTOM_MARGIN + - (onlyToolRows ? 0 : WORK_LOG_HEADER_HEIGHT) + + (onlyToolRows ? 0 : headerHeight) + rows.length * WORK_ROW_HEIGHT + (rows.length - 1) * WORK_ROW_GAP ); diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index 7287eabba7c..d2504a629dd 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -198,12 +198,27 @@ export function resolveTextScaleVariables(baseFontSize: number): Record