Skip to content

Commit 4ebe4b8

Browse files
authored
Merge pull request #249 from pylon-code/upstream/2026-09-02-scroll-bounds
fix(mobile): keep thread scroll bounds current after animations
2 parents 9ecf588 + 0a67e69 commit 4ebe4b8

7 files changed

Lines changed: 253 additions & 164 deletions

File tree

‎apps/mobile/src/features/threads/ThreadDetailScreen.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
789789
onTouchCancel={handleFeedTouchCancel}
790790
>
791791
<ThreadFeed
792-
key={props.selectedThread.id}
792+
key={selectedThreadKey}
793793
environmentId={props.environmentId}
794794
threadId={props.selectedThread.id}
795795
workspaceRoot={props.threadCwd}

‎apps/mobile/src/features/threads/ThreadFeed.tsx‎

Lines changed: 30 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
7171
import Animated, {
7272
FadeIn,
7373
FadeInUp,
74-
FadeOut,
7574
LinearTransition,
7675
type SharedValue,
7776
} from "react-native-reanimated";
@@ -133,6 +132,7 @@ import {
133132
} from "./thread-feed-live-follow";
134133
import {
135134
collapsedWorkLogHeight,
135+
ThreadDisclosureChevron,
136136
ThreadWorkGroupToggle,
137137
ThreadWorkLog,
138138
THREAD_DISCLOSURE_TRANSITION_MS,
@@ -167,11 +167,12 @@ function formatMessageTime(input: string): string {
167167
// text-sm line at every supported base font size (26px at the 22pt maximum),
168168
// so its height is a constant; a drifted value costs one correction on
169169
// measure, not a persistent offset.
170-
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
170+
const TURN_FOLD_HEIGHT = 48; // min-h-11 (44) + mb-1 (4)
171171
const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
172-
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.duration(140);
173-
const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut.duration(120);
174-
const EMPTY_DISCLOSURE_ENTRY_IDS: ReadonlySet<string> = new Set();
172+
// Let neighboring rows move out of the new rows' space before showing their text.
173+
const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay(
174+
THREAD_DISCLOSURE_TRANSITION_MS,
175+
).duration(140);
175176

176177
// Entering animations must only play for rows born just now — LegendList
177178
// remounts rows when they scroll back into view, and replaying an entrance for
@@ -1357,16 +1358,16 @@ function renderFeedEntry(
13571358
accessibilityState={{ expanded: entry.expanded }}
13581359
onPress={() => props.onToggleTurnFold(entry.turnId)}
13591360
hitSlop={4}
1360-
className="mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
1361+
className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
13611362
>
13621363
<Text className="font-t3-medium text-sm tabular-nums text-foreground-muted">
13631364
{entry.label}
13641365
</Text>
1365-
<SymbolView
1366-
name={entry.expanded ? "chevron.down" : "chevron.right"}
1366+
<ThreadDisclosureChevron
1367+
expanded={entry.expanded}
1368+
collapsedDirection="right"
13671369
size={15}
1368-
tintColorClassName={"accent-icon-subtle"}
1369-
type="monochrome"
1370+
tintColor={iconSubtleColor}
13701371
/>
13711372
</Pressable>
13721373
);
@@ -1490,7 +1491,7 @@ function renderFeedEntry(
14901491
const enterAnimated = isFreshTimestamp(message.createdAt);
14911492
return (
14921493
<Animated.View
1493-
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}
1494+
className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1")}
14941495
{...(enterAnimated ? { entering: FadeIn.duration(220) } : {})}
14951496
>
14961497
{renderedText.trim().length > 0 ? (
@@ -1840,7 +1841,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18401841
const disclosureSettleFrameRef = useRef<number | null>(null);
18411842
const disclosureSettleSecondFrameRef = useRef<number | null>(null);
18421843
const disclosureAnchorKeyRef = useRef<string | null>(null);
1843-
const previousPresentedFeedRef = useRef<ReadonlyArray<ThreadFeedEntry> | null>(null);
18441844
const headerMaterialVisibleRef = useRef(false);
18451845
const previousLatestTurnRef = useRef(props.latestTurn);
18461846
const userScrollSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -1853,12 +1853,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18531853
// Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
18541854
// whenever the viewport drifts back inside its geometric threshold, which
18551855
// yanked users off history they were reading every time a stream chunk grew
1856-
// a row. Follow breaks when the user scrolls up and away, and re-arms only
1857-
// when the list actually returns to the end (or on send / thread switch).
1856+
// a row. Scrolling away or expanding a disclosure above the end breaks
1857+
// follow; reaching the end (or sending / switching threads) re-arms it.
18581858
const [endFollowEnabled, setEndFollowEnabled] = useState(true);
18591859
const endFollowEnabledRef = useRef(true);
18601860
// A "user scroll session" spans from drag start through the end of its
1861-
// momentum; only motion inside a session can break follow, so MVCP
1861+
// momentum; scroll events only break follow inside that session, so MVCP
18621862
// compensations and programmatic scrolls never strand a follower.
18631863
const userScrollSessionRef = useRef(false);
18641864
const setEndFollow = useCallback(
@@ -2173,33 +2173,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
21732173
props.latestTurn,
21742174
],
21752175
);
2176-
const disclosureEnteringEntryIds = useMemo(() => {
2177-
const anchorKey = disclosureAnchorKeyRef.current;
2178-
const previousPresentedFeed = previousPresentedFeedRef.current;
2179-
if (!disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null) {
2180-
return EMPTY_DISCLOSURE_ENTRY_IDS;
2181-
}
2182-
2183-
const previousIds = new Set(previousPresentedFeed.map((entry) => entry.id));
2184-
const anchorIndex = presentedFeed.findIndex((entry) => entry.id === anchorKey);
2185-
const enteringIds = new Set<string>();
2186-
if (anchorIndex < 0) {
2187-
return enteringIds;
2188-
}
2189-
for (let index = anchorIndex + 1; index < presentedFeed.length; index += 1) {
2190-
const entryId = presentedFeed[index]!.id;
2191-
if (previousIds.has(entryId)) {
2192-
break;
2193-
}
2194-
enteringIds.add(entryId);
2195-
}
2196-
return enteringIds;
2197-
}, [disclosureToggleSettling, presentedFeed]);
2198-
2199-
useLayoutEffect(() => {
2200-
previousPresentedFeedRef.current = presentedFeed;
2201-
}, [presentedFeed]);
2202-
22032176
// The empty↔filled key below remounts the list and resets its imperative
22042177
// content-inset override. Seed the fresh instance synchronously with the
22052178
// current overlay height before the scroll integration's next reaction;
@@ -2286,13 +2259,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22862259
}
22872260
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
22882261
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
2262+
// A disclosure can leave the reader above the end without a drag.
2263+
// Reconcile follow before a later layout or resume can re-pin it.
2264+
const listState = props.listRef.current?.getState();
2265+
if (listState) {
2266+
transitionEndFollow({
2267+
type: "disclosure-settled",
2268+
isAtEnd: listState.isAtEnd,
2269+
userScrollSessionActive: userScrollSessionRef.current,
2270+
});
2271+
}
22892272
disclosureAnchorKeyRef.current = null;
22902273
setDisclosureToggleSettling(false);
22912274
disclosureSettleFrameRef.current = null;
22922275
disclosureSettleSecondFrameRef.current = null;
22932276
});
22942277
});
2295-
}, []);
2278+
}, [props.listRef, transitionEndFollow]);
22962279

22972280
const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
22982281
disclosureAnchorKeyRef.current = anchorKey;
@@ -2433,16 +2416,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24332416
[expandedWorkRows],
24342417
);
24352418

2419+
// Disclosures can mount existing offscreen rows as well as new work rows.
2420+
// Fade those in after movement; never retain removed rows over replacements.
24362421
const renderItem = useCallback(
24372422
(info: { item: ThreadFeedEntry; index: number }) => (
24382423
<Animated.View
24392424
key={info.item.id}
2440-
entering={
2441-
disclosureEnteringEntryIds.has(info.item.id)
2442-
? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2443-
: undefined
2444-
}
2445-
exiting={THREAD_FEED_DISCLOSURE_EXIT_TRANSITION}
2425+
entering={disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined}
24462426
>
24472427
{renderFeedEntry(info, {
24482428
environmentId: props.environmentId,
@@ -2471,7 +2451,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24712451
),
24722452
[
24732453
copiedRowId,
2474-
disclosureEnteringEntryIds,
2454+
disclosureToggleSettling,
24752455
expandedWorkRows,
24762456
terminalAssistantMessageIds,
24772457
unsettledTurnId,

‎apps/mobile/src/features/threads/thread-feed-live-follow.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,17 @@ describe("resolveThreadFeedLiveFollow", () => {
102102
).toBe(false);
103103
});
104104

105+
it.each([
106+
{ isAtEnd: false, userScrollSessionActive: false, expected: false },
107+
{ isAtEnd: true, userScrollSessionActive: false, expected: true },
108+
{ isAtEnd: false, userScrollSessionActive: true, expected: false },
109+
{ isAtEnd: true, userScrollSessionActive: true, expected: false },
110+
])("reconciles follow after a disclosure settles: %j", ({ expected, ...state }) => {
111+
expect(resolveThreadFeedLiveFollow(!expected, { type: "disclosure-settled", ...state })).toBe(
112+
expected,
113+
);
114+
});
115+
105116
it("re-arms at the actual end only after the user scroll session ends", () => {
106117
expect(
107118
resolveThreadFeedLiveFollow(false, {

‎apps/mobile/src/features/threads/thread-feed-live-follow.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export type ThreadFeedLiveFollowEvent =
77
readonly userScrollSessionActive: boolean;
88
}
99
| {
10-
readonly type: "scroll";
10+
readonly type: "scroll" | "disclosure-settled";
1111
readonly isAtEnd: boolean;
1212
readonly userScrollSessionActive: boolean;
1313
};
@@ -41,6 +41,8 @@ export function resolveThreadFeedLiveFollow(
4141
return false;
4242
case "user-scroll-end":
4343
return event.userScrollSessionActive ? event.isAtEnd : current;
44+
case "disclosure-settled":
45+
return !event.userScrollSessionActive && event.isAtEnd;
4446
case "scroll":
4547
if (event.userScrollSessionActive) {
4648
return false;

‎apps/mobile/src/features/threads/thread-work-log.tsx‎

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as Haptics from "expo-haptics";
22
import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
33
import { MaskedView } from "@expo/ui/community/masked-view";
44
import { useIsFocused } from "@react-navigation/native";
5-
import { useEffect, useId, useState, type ComponentProps } from "react";
5+
import { useEffect, useId, useLayoutEffect, useState, type ComponentProps } from "react";
66
import {
77
AccessibilityInfo,
88
AppState,
@@ -41,6 +41,44 @@ const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_T
4141
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
4242
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
4343

44+
export function ThreadDisclosureChevron(props: {
45+
readonly expanded: boolean;
46+
readonly collapsedDirection: "right" | "down";
47+
readonly size: number;
48+
readonly tintColor: ColorValue;
49+
}) {
50+
const expandedAngle = props.collapsedDirection === "right" ? 90 : 180;
51+
const rotation = useSharedValue(props.expanded ? expandedAngle : 0);
52+
53+
useLayoutEffect(() => {
54+
rotation.value = withTiming(props.expanded ? expandedAngle : 0, {
55+
duration: THREAD_DISCLOSURE_TRANSITION_MS,
56+
reduceMotion: ReduceMotion.System,
57+
});
58+
}, [expandedAngle, props.expanded, rotation]);
59+
60+
const rotationStyle = useAnimatedStyle(() => ({
61+
transform: [{ rotate: `${rotation.value}deg` }],
62+
}));
63+
64+
return (
65+
<Animated.View
66+
accessible={false}
67+
accessibilityElementsHidden
68+
importantForAccessibility="no-hide-descendants"
69+
pointerEvents="none"
70+
style={[{ width: props.size, height: props.size }, rotationStyle]}
71+
>
72+
<SymbolView
73+
name={props.collapsedDirection === "right" ? "chevron.right" : "chevron.down"}
74+
size={props.size}
75+
tintColor={props.tintColor}
76+
type="monochrome"
77+
/>
78+
</Animated.View>
79+
);
80+
}
81+
4482
function ShimmerWorkContent(props: {
4583
readonly highlighted: boolean;
4684
readonly icon: AppSymbolName;
@@ -264,7 +302,7 @@ const WORK_ROW_HEIGHT = 32; // min-h-8
264302
const WORK_ROW_GAP = 1; // gap-px
265303
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1
266304

267-
export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)
305+
export const WORK_GROUP_TOGGLE_HEIGHT = 32; // min-h-8
268306

269307
export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
270308
if (activities.length === 0) {
@@ -395,15 +433,11 @@ export function ThreadWorkLog(props: {
395433
) : null}
396434
<View className="h-4 w-4 items-center justify-center">
397435
{canExpand ? (
398-
<SymbolView
399-
name={
400-
expanded
401-
? { ios: "chevron.up", android: "keyboard_arrow_up" }
402-
: { ios: "chevron.down", android: "keyboard_arrow_down" }
403-
}
436+
<ThreadDisclosureChevron
437+
expanded={expanded}
438+
collapsedDirection="down"
404439
size={11}
405440
tintColor={props.iconSubtleColor}
406-
type="monochrome"
407441
/>
408442
) : null}
409443
</View>
@@ -458,7 +492,7 @@ export function ThreadWorkGroupToggle(props: {
458492
const icon = toolGroupSummarySymbolName(props.summaryKind);
459493

460494
return (
461-
<View className="-mx-1 mb-1 px-1 py-0">
495+
<View className="-mx-1 px-1 py-0">
462496
<Pressable
463497
accessibilityRole="button"
464498
accessibilityState={{ expanded: props.expanded }}
@@ -493,15 +527,11 @@ export function ThreadWorkGroupToggle(props: {
493527
</Text>
494528
</>
495529
)}
496-
<SymbolView
497-
name={
498-
props.expanded
499-
? { ios: "chevron.up", android: "keyboard_arrow_up" }
500-
: { ios: "chevron.down", android: "keyboard_arrow_down" }
501-
}
530+
<ThreadDisclosureChevron
531+
expanded={props.expanded}
532+
collapsedDirection="down"
502533
size={11}
503534
tintColor={props.iconSubtleColor}
504-
type="monochrome"
505535
/>
506536
</Pressable>
507537
</View>

0 commit comments

Comments
 (0)