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
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);
}, [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