Skip to content
Closed
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
12 changes: 10 additions & 2 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -317,6 +320,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
const [submittedMessageId, setSubmittedMessageId] = useState<MessageId | null>(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
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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}
Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SelectableMarkdownSkill>;
readonly onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void;
/** Non-null when older turns exist beyond the loaded window. */
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium threads/ThreadFeed.tsx:2433

When a row grows from asynchronous measurement or streaming without a scroll event, the parent keeps isAtEnd: true even though the reader is above the end, so shouldShowThreadFeedScrollToEnd stays hidden. This subscription only observes LegendList's stored observable; size-only changes require querying getState(). Update the parent from getState().isAtEnd in onItemSizeChanged for all row resizes, not only during disclosure settling.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around line 2433:

When a row grows from asynchronous measurement or streaming without a scroll event, the parent keeps `isAtEnd: true` even though the reader is above the end, so `shouldShowThreadFeedScrollToEnd` stays hidden. This subscription only observes LegendList's stored observable; size-only changes require querying `getState()`. Update the parent from `getState().isAtEnd` in `onItemSizeChanged` for all row resizes, not only during disclosure settling.

}, [listMountKey, props.listRef, props.onIsAtEndChange]);

const anchoredEndSpace = useMemo(
() =>
resolveChatListAnchoredEndSpace(
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
38 changes: 38 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
resolveThreadFeedSubmissionAnchor,
resolveThreadWorkGroupInitialScroll,
shouldFollowThreadWorkGroupAppend,
shouldShowThreadFeedScrollToEnd,
} from "./thread-feed-live-follow";

describe("tool-group scroll restoration", () => {
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/features/threads/thread-feed-live-follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ export function resolveThreadFeedSubmissionAnchor<AnchorId>(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,
Expand Down
Loading