Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
38 changes: 36 additions & 2 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -295,6 +298,16 @@ const WIDE_MARKDOWN_BLOCK_OPTIONS = {
includeOrderedLists: Platform.OS === "android",
} as const;

function nextFrames(count: number): Promise<void> {
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;
Expand Down Expand Up @@ -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,
Comment thread
nabettu marked this conversation as resolved.
Resolved
});
void Promise.resolve(positioning).then(() => {
if (!cancelled && listRef.current === list) props.onJumpedToMessage?.();
});
Expand Down Expand Up @@ -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={
<>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -92,3 +93,41 @@ describe("resolveThreadFeedSubmissionAnchor", () => {
).toBeNull();
});
});

describe("settleThreadFeedJump", () => {
const landings = (positions: ReadonlyArray<number | undefined>) => {
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);
});
});
22 changes: 22 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-scroll-position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,25 @@ export function resolveThreadFeedSubmissionAnchor<AnchorId>(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<void>;
readonly position: () => number | undefined;
readonly cancelled: () => boolean;
}): Promise<void> {
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;
}
}
Loading