From 176ec9d9de24b64ad8cb097efd5e36b224957305 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:28:03 +0200 Subject: [PATCH 1/2] Revert "fix(mobile): keep conversation feed visible (#232)" This reverts commit bb01fe8d5062745b30ae4116ffbad47aa4876efa. --- .../features/threads/ThreadRouteScreen.tsx | 4 +-- .../mobile/src/mobileSurfaceExistence.test.ts | 29 ------------------- 2 files changed, 2 insertions(+), 31 deletions(-) delete mode 100644 apps/mobile/src/mobileSurfaceExistence.test.ts diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 116012a894d..d9741cd51b5 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -764,12 +764,12 @@ function ThreadRouteContent( const renderThreadRouteBody = (showActionControls: boolean) => ( // A real flex host (not a fragment) keeps the thread body filling the // screen so the absolute composer overlay anchors to the true bottom. - + - + { - it("keeps the conversation feed inside an explicit flex host", () => { - const threadRoute = readSrc("features/threads/ThreadRouteScreen.tsx"); - - expect(threadRoute).toContain('testID="thread-conversation-surface"'); - expect(threadRoute).toMatch( - /testID="thread-conversation-surface"[\s\S]*?style=\{\{ flex: 1 \}\}/, - ); - expect(threadRoute).toMatch( - /[\s\S]*? Date: Thu, 30 Jul 2026 21:28:30 +0200 Subject: [PATCH 2/2] Revert "fix(mobile): reduce thread feed scroll jank (#4874)" This reverts commit 9e3e9bb4f081a6ad331cb94b42b2bd2d93339b81. --- .../src/features/threads/ThreadFeed.tsx | 112 +----------------- .../src/features/threads/thread-work-log.tsx | 49 +------- apps/mobile/src/lib/appearancePreferences.ts | 17 +-- 3 files changed, 10 insertions(+), 168 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 98e3fb03283..4c8ac3ae943 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -48,9 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, - useSharedValue, - withTiming, - type LayoutAnimationsValues, + LinearTransition, type SharedValue, } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -82,9 +80,7 @@ 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"; @@ -95,12 +91,7 @@ import { type ThreadFeedLatestTurn, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; -import { - collapsedWorkLogHeight, - ThreadWorkGroupToggle, - ThreadWorkLog, - WORK_GROUP_TOGGLE_HEIGHT, -} from "./thread-work-log"; +import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log"; import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; import { useAssetUrl } from "../../state/assets"; import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; @@ -118,22 +109,8 @@ 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. 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. 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) -// 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) +// animating the container position turns those jumps into slides. +const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180); // Entering animations must only play for rows born just now — LegendList // remounts rows when they scroll back into view, and replaying an entrance for @@ -1340,7 +1317,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const userNavigationInProgressRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); const { width: windowWidth } = useWindowDimensions(); - const { appearance } = useAppearancePreferences(); const [viewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); @@ -1454,11 +1430,6 @@ 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 @@ -1467,8 +1438,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // header height back or the material toggles a full header too late. reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); const { contentInset, contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - nearListEnd.value = - contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; const distanceFromEnd = contentSize.height + contentInset.bottom - contentOffset.y - layoutMeasurement.height; const nextIsAtEnd = distanceFromEnd <= FEED_END_THRESHOLD; @@ -1486,36 +1455,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setHasUnreadActivity(false); } }, - [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd], + [reportHeaderMaterialVisibility, anchorTopInset], ); - - // 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 handleScrollBeginDrag = useCallback(() => { userNavigationInProgressRef.current = true; }, []); @@ -1783,38 +1724,6 @@ 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. 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) { - case "turn-fold": - return TURN_FOLD_HEIGHT; - case "work-toggle": - return WORK_GROUP_TOGGLE_HEIGHT; - case "working": - 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, appearance.baseFontSize); - default: - return undefined; - } - }, - [expandedWorkRows, workingRowHeight, appearance.baseFontSize], - ); - const renderItem = useCallback( (info: { item: ThreadFeedEntry; index: number }) => renderFeedEntry(info, { @@ -1904,7 +1813,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - itemLayoutAnimation={feedItemLayoutTransition} + itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION} // 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 @@ -1950,10 +1859,6 @@ 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" @@ -1973,11 +1878,6 @@ 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} onStartReached={onStartReachedOlderHistory} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 529adac1db3..0896335ea96 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -3,10 +3,8 @@ 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 = { @@ -79,46 +77,6 @@ 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 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_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, - 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 : headerHeight) + - rows.length * WORK_ROW_HEIGHT + - (rows.length - 1) * WORK_ROW_GAP - ); -} - export function ThreadWorkLog(props: { readonly activities: ReadonlyArray; readonly copiedRowId: string | null; @@ -129,10 +87,9 @@ 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 = visibleWorkLogActivities(props.activities).map((activity) => ({ - ...activity, - detail: compactActivityDetail(activity.detail), - })); + const rows = props.activities + .filter((activity) => !(activity.toolLike && activity.status === "neutral")) + .map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) })); if (rows.length === 0) { return null; diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index d2504a629dd..7287eabba7c 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -198,27 +198,12 @@ export function resolveTextScaleVariables(baseFontSize: number): Record