diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index eebc6b8696450..86ce15a772f89 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1411,6 +1411,8 @@ function ThreadDetailBody( const handleScrollToEnd = () => { void Haptics.selectionAsync(); + // A tap does not begin a drag, so end a landing search jump here or it scrolls back. + finishThreadMessageJump(selectedThreadKey); void scrollMessageToEnd({ animated: !reduceMotion, closeKeyboard: false }).catch(() => { freeze.set(false); }); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a98cb2d93287e..cc8ebafa17ba2 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -208,7 +208,10 @@ import { messageFoldRunId, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; -import { type ThreadWorkGroupScrollPosition } from "./thread-feed-scroll-position"; +import { + settleThreadFeedJump, + type ThreadWorkGroupScrollPosition, +} from "./thread-feed-scroll-position"; import { collapsedWorkLogHeight, ThreadDisclosureChevron, @@ -295,6 +298,16 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = { includeOrderedLists: Platform.OS === "android", } as const; +function nextFrames(count: number): Promise { + return new Promise((resolve) => { + const tick = (remaining: number) => { + if (remaining === 0) resolve(); + else requestAnimationFrame(() => tick(remaining - 1)); + }; + tick(count); + }); +} + const TURN_FOLD_HEIGHT = 42; const FEED_PADDING_TOP = 12; const VIEWED_IMAGE_GAP = 8; @@ -3433,12 +3446,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const jumpIndex = jumpReady ? presentedFeed.findIndex((entry) => entry.type === "message" && entry.id === jumpToMessageId) : null; + const jumpOverriddenRef = useRef(false); const landJump = useEffectEvent((index: number) => { const list = listRef.current; if (!list) return; let cancelled = false; + jumpOverriddenRef.current = false; const positioning = - index === -1 ? undefined : list.scrollToIndex({ index, viewPosition: 0.2, animated: false }); + index === -1 + ? undefined + : settleThreadFeedJump({ + land: async () => { + // A fractional viewPosition scales with the row, so a message taller + // than the screen would open past its first lines. + await list.scrollToIndex({ + index, + viewPosition: 0, + viewOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET, + animated: false, + }); + await nextFrames(4); + }, + position: () => list.getState().positionAtIndex(index), + cancelled: () => cancelled || jumpOverriddenRef.current, + }); void Promise.resolve(positioning).then(() => { if (!cancelled && listRef.current === list) props.onJumpedToMessage?.(); }); @@ -3881,6 +3912,9 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onLayout={handleMetricsChange} onItemSizeChanged={handleItemSizeChanged} onScroll={handleScroll} + onScrollBeginDrag={() => { + jumpOverriddenRef.current = true; + }} scrollEventThrottle={16} ListHeaderComponent={ <> diff --git a/apps/mobile/src/features/threads/thread-feed-scroll-position.test.ts b/apps/mobile/src/features/threads/thread-feed-scroll-position.test.ts index 89816db6467ed..34eabf459ea5f 100644 --- a/apps/mobile/src/features/threads/thread-feed-scroll-position.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-scroll-position.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { resolveThreadFeedSubmissionAnchor, resolveThreadWorkGroupInitialScroll, + settleThreadFeedJump, } from "./thread-feed-scroll-position"; describe("tool-group scroll restoration", () => { @@ -92,3 +93,41 @@ describe("resolveThreadFeedSubmissionAnchor", () => { ).toBeNull(); }); }); + +describe("settleThreadFeedJump", () => { + const landings = (positions: ReadonlyArray) => { + let lands = 0; + return { + land: async () => { + lands += 1; + }, + position: () => positions[Math.min(lands, positions.length - 1)], + lands: () => lands, + }; + }; + + it("lands again until the measured rows stop moving the target", async () => { + // The first landing trusts estimated rows; measuring them moves the target. + const feed = landings([14389, 13563, 13480, 13480]); + await settleThreadFeedJump({ ...feed, cancelled: () => false }); + expect(feed.lands()).toBe(3); + }); + + it("keeps landing while the target has no position yet", async () => { + const feed = landings([undefined, 700, 700]); + await settleThreadFeedJump({ ...feed, cancelled: () => false }); + expect(feed.lands()).toBe(2); + }); + + it("gives up on a target that never settles", async () => { + const feed = landings(Array.from({ length: 50 }, (_, index) => index * 100)); + await settleThreadFeedJump({ ...feed, cancelled: () => false }); + expect(feed.lands()).toBeLessThan(10); + }); + + it("stops landing once the user takes the scroll", async () => { + const feed = landings([14389, 13563, 13480]); + await settleThreadFeedJump({ ...feed, cancelled: () => feed.lands() >= 1 }); + expect(feed.lands()).toBe(1); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-feed-scroll-position.ts b/apps/mobile/src/features/threads/thread-feed-scroll-position.ts index 58e9ed34248a6..03ca3e13727f3 100644 --- a/apps/mobile/src/features/threads/thread-feed-scroll-position.ts +++ b/apps/mobile/src/features/threads/thread-feed-scroll-position.ts @@ -30,3 +30,25 @@ export function resolveThreadFeedSubmissionAnchor(input: { return input.queuedMessageCount > 0 ? null : input.submittedMessageId; } + +const JUMP_LANDING_LIMIT = 6; + +/** + * Lands on a message until it stops moving. Rows above an unvisited target are + * estimates, so the first landing stops well away from it, and measuring the + * rows there moves the target while the list holds the visible content still. + */ +export async function settleThreadFeedJump(input: { + readonly land: () => Promise; + readonly position: () => number | undefined; + readonly cancelled: () => boolean; +}): Promise { + let previous = input.position(); + for (let landing = 0; landing < JUMP_LANDING_LIMIT; landing += 1) { + await input.land(); + if (input.cancelled()) return; + const next = input.position(); + if (previous !== undefined && next !== undefined && Math.abs(next - previous) <= 1) return; + previous = next; + } +}