diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2f4ee67a187e..78ef6d41aa39 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -106,7 +106,10 @@ import { } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; -import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; +import { + resolveThreadFeedSubmissionAnchor, + shouldShowThreadFeedScrollToEnd, +} from "./thread-feed-live-follow"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; @@ -317,6 +320,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [anchorMessageId, setAnchorMessageId] = useState(null); const [submittedMessageId, setSubmittedMessageId] = useState(null); const [endFollowEnabled, setEndFollowEnabled] = useState(true); + const [isAtEnd, setIsAtEnd] = useState(true); // Android keys the safe-area padding on keyboard visibility (#5988): the // back gesture closes the keyboard while the editor stays focused, and a // focus-keyed inset would leave the toolbar under the gesture bar. iOS must @@ -679,6 +683,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread setSubmittedMessageId(null); lastScrolledSubmittedMessageIdRef.current = null; setEndFollowEnabled(true); + setIsAtEnd(true); freeze.set(false); }, [freeze, selectedThreadKey]); @@ -818,7 +823,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread }); }, [freeze, scrollMessageToEnd]); - const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; + const showScrollToEndButton = + contentPresentationKind === "ready" && + shouldShowThreadFeedScrollToEnd({ endFollowEnabled, isAtEnd }); const { themeAppearance, materialYouStyleLayoutActive } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; @@ -899,6 +906,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} onEndFollowEnabledChange={setEndFollowEnabled} + onIsAtEndChange={setIsAtEnd} skills={selectedProviderSkills} onUseArtifactTemplate={handleUseArtifactTemplate} loadEarlier={props.loadEarlier ?? null} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 66c4359b8190..c3718a28252d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -260,6 +260,7 @@ export interface ThreadFeedProps { readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; + readonly onIsAtEndChange?: (isAtEnd: boolean) => void; readonly skills?: ReadonlyArray; readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; /** Non-null when older turns exist beyond the loaded window. */ @@ -2421,6 +2422,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); + // Subscribe to edge transitions without updating the screen on every scroll. + useLayoutEffect(() => { + const listState = props.listRef.current?.getState(); + const onIsAtEndChange = props.onIsAtEndChange; + if (!listState || !onIsAtEndChange) { + return; + } + onIsAtEndChange(listState.isAtEnd); + return listState.listen("isAtEnd", onIsAtEndChange); + }, [listMountKey, props.listRef, props.onIsAtEndChange]); + const anchoredEndSpace = useMemo( () => resolveChatListAnchoredEndSpace( @@ -2493,6 +2505,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // Reconcile follow before a later layout or resume can re-pin it. const listState = props.listRef.current?.getState(); if (listState) { + // Row resizing can change the end without notifying the edge subscription. + props.onIsAtEndChange?.(listState.isAtEnd); transitionEndFollow({ type: "disclosure-settled", isAtEnd: listState.isAtEnd, @@ -2505,7 +2519,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { disclosureSettleSecondFrameRef.current = null; }); }); - }, [props.listRef, transitionEndFollow]); + }, [props.listRef, props.onIsAtEndChange, transitionEndFollow]); const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => { disclosureAnchorKeyRef.current = anchorKey; diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts index 13e81130823a..1db80e1b53c1 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -5,6 +5,7 @@ import { resolveThreadFeedSubmissionAnchor, resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, + shouldShowThreadFeedScrollToEnd, } from "./thread-feed-live-follow"; describe("tool-group scroll restoration", () => { @@ -144,6 +145,43 @@ describe("resolveThreadFeedSubmissionAnchor", () => { }); }); +describe("scroll-to-end visibility", () => { + it("keeps the button hidden when dragging further down at the bottom", () => { + let endFollowEnabled = resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" }); + expect(endFollowEnabled).toBe(false); + expect(shouldShowThreadFeedScrollToEnd({ endFollowEnabled, isAtEnd: true })).toBe(false); + + endFollowEnabled = resolveThreadFeedLiveFollow(endFollowEnabled, { + type: "scroll", + isAtEnd: true, + userScrollSessionActive: true, + }); + expect(shouldShowThreadFeedScrollToEnd({ endFollowEnabled, isAtEnd: true })).toBe(false); + }); + + it("shows the button after scrolling up and hides it on returning to the bottom", () => { + let endFollowEnabled = resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" }); + endFollowEnabled = resolveThreadFeedLiveFollow(endFollowEnabled, { + type: "scroll", + isAtEnd: false, + userScrollSessionActive: true, + }); + expect(shouldShowThreadFeedScrollToEnd({ endFollowEnabled, isAtEnd: false })).toBe(true); + + endFollowEnabled = resolveThreadFeedLiveFollow(endFollowEnabled, { + type: "scroll", + isAtEnd: true, + userScrollSessionActive: true, + }); + expect(endFollowEnabled).toBe(false); + expect(shouldShowThreadFeedScrollToEnd({ endFollowEnabled, isAtEnd: true })).toBe(false); + }); + + it("keeps the button hidden while following streaming content", () => { + expect(shouldShowThreadFeedScrollToEnd({ endFollowEnabled: true, isAtEnd: false })).toBe(false); + }); +}); + describe("resolveThreadFeedLiveFollow", () => { it("pauses immediately when the user starts scrolling", () => { expect(resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" })).toBe(false); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts index 431dda7550d1..b0b257d5eb36 100644 --- a/apps/mobile/src/features/threads/thread-feed-live-follow.ts +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -65,6 +65,14 @@ export function resolveThreadFeedSubmissionAnchor(input: { return input.queuedMessageCount > 0 ? null : input.submittedMessageId; } +export function shouldShowThreadFeedScrollToEnd(input: { + readonly endFollowEnabled: boolean; + readonly isAtEnd: boolean; +}) { + // A drag pauses live-follow before moving away from the end. + return !input.endFollowEnabled && !input.isAtEnd; +} + export function resolveThreadFeedLiveFollow( current: boolean, event: ThreadFeedLiveFollowEvent,