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
37 changes: 33 additions & 4 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,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 worktreeSetup?: WorktreeSetupCardProps | null;
Expand Down Expand Up @@ -263,7 +266,23 @@ const USER_INPUT_TOGGLE_TIMING = {
easing: Easing.out(Easing.cubic),
};

export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
/** Hide the scroll-to-end control until the feed is ready and away from the live edge. */
function shouldShowThreadDetailScrollToEnd(input: {
readonly contentPresentationKind: ThreadContentPresentation["kind"];
readonly endFollowEnabled: boolean;
readonly isAtEnd: boolean;
}) {
return (
input.contentPresentationKind === "ready" &&
shouldShowThreadFeedScrollToEnd({
endFollowEnabled: input.endFollowEnabled,
isAtEnd: input.isAtEnd,
})
);
}

/** Thread transcript and composer; hides scroll-to-end while the feed is already at the live edge. */
function ThreadDetailScreenView(props: ThreadDetailScreenProps) {
const navigation = useNavigation();
const deviceState = useEnvironmentQuery(
deviceEnvironment.state({ environmentId: props.environmentId, input: {} }),
Expand Down Expand Up @@ -337,6 +356,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 @@ -702,6 +722,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 @@ -841,7 +862,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
});
}, [freeze, scrollMessageToEnd]);

const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
const showScrollToEndButton = shouldShowThreadDetailScrollToEnd({
contentPresentationKind,
endFollowEnabled,
isAtEnd,
});
const { themeAppearance } = useAppearancePreferences();
const isDarkMode = themeAppearance === "dark";

Expand Down Expand Up @@ -933,6 +958,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 Expand Up @@ -1108,4 +1134,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
) : null}
</View>
);
});
}

export const ThreadDetailScreen = memo(ThreadDetailScreenView);
ThreadDetailScreen.displayName = "ThreadDetailScreen";
250 changes: 191 additions & 59 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,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 @@ -1918,6 +1919,149 @@ function LegacyUserMessageContent(props: UserMessageContentProps) {
);
}

/** Map LegendList state to the exact-end and near-end flags live-follow uses to re-arm. */
function readThreadFeedEndState(
listState:
| {
readonly isAtEnd: boolean;
readonly isWithinMaintainScrollAtEndThreshold: boolean;
}
| null
| undefined,
) {
return {
isAtEnd: listState?.isAtEnd ?? false,
nearEnd: listState?.isWithinMaintainScrollAtEndThreshold ?? false,
};
}

/** Report the current end edge and subscribe to later isAtEnd transitions. */
function subscribeThreadFeedIsAtEnd(
listRef: RefObject<LegendListRef | null>,
onIsAtEndChange: ((isAtEnd: boolean) => void) | undefined,
) {
const listState = listRef.current?.getState();
if (!listState || !onIsAtEndChange) {
return;
}
onIsAtEndChange(listState.isAtEnd);
return listState.listen("isAtEnd", onIsAtEndChange);
}

/** Re-arm live-follow from LegendList exact-end and near-end flags while scrolling. */
function handleThreadFeedScroll(input: {
readonly event: NativeSyntheticEvent<NativeScrollEvent>;
readonly listRef: RefObject<LegendListRef | null>;
readonly userScrollSessionRef: { current: boolean };
readonly anchorTopInset: number;
readonly reportHeaderMaterialVisibility: (visible: boolean) => void;
readonly transitionEndFollow: (event: ThreadFeedLiveFollowEvent) => void;
}) {
// anchorTopInset, not topContentInset: under automatic insets the list
// rests at contentOffset.y = -headerHeight (the inset lives only in
// UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
// header height back or the material toggles a full header too late.
input.reportHeaderMaterialVisibility(
input.event.nativeEvent.contentOffset.y + input.anchorTopInset > 6,
);
// LegendList recomputes its inset-aware end distance before invoking
// this handler, so getState() is current. Follow re-arms at the actual
// end or anywhere inside LegendList's maintain-at-end tolerance: a swipe
// back to the live edge usually rests a few pixels short of the exact
// end, in space the end inset covers, and the strict test would leave
// the scroll-to-end control stuck visible. A live user-scroll session
// still wins even if the first scroll event remains inside LegendList's
// at-end tolerance, so a streaming chunk cannot pull a drag back.
const listState = input.listRef.current?.getState();
if (listState) {
input.transitionEndFollow({
type: "scroll",
...readThreadFeedEndState(listState),
userScrollSessionActive: input.userScrollSessionRef.current,
});
}
}

/** End a user-scroll session using the finger-release end position, not later stream growth. */
function finishThreadFeedUserScroll(input: {
readonly release?: { readonly isAtEnd: boolean; readonly nearEnd: boolean };
readonly listRef: RefObject<LegendListRef | null>;
readonly userScrollSessionRef: { current: boolean };
readonly clearUserScrollSettle: () => void;
readonly transitionEndFollow: (event: ThreadFeedLiveFollowEvent) => void;
}) {
input.clearUserScrollSettle();
const userScrollSessionActive = input.userScrollSessionRef.current;
input.userScrollSessionRef.current = false;
const fallback = readThreadFeedEndState(input.listRef.current?.getState());
input.transitionEndFollow({
type: "user-scroll-end",
// With no momentum, preserve the finger-release position (at or within
// the re-arm tolerance of the live edge). Streaming growth during the
// native momentum-detection window must not turn a release at the live
// edge into an opt-out from follow, or a release away from it into an
// opt-in.
isAtEnd: input.release?.isAtEnd ?? fallback.isAtEnd,
nearEnd: input.release?.nearEnd ?? fallback.nearEnd,
userScrollSessionActive,
});
}

/** Snapshot the finger-release end position in case native momentum never starts. */
function scheduleThreadFeedUserScrollFinish(input: {
readonly listRef: RefObject<LegendListRef | null>;
readonly userScrollSettleTimerRef: { current: ReturnType<typeof setTimeout> | null };
readonly finishUserScroll: (release: {
readonly isAtEnd: boolean;
readonly nearEnd: boolean;
}) => void;
readonly clearUserScrollSettle: () => void;
}) {
input.clearUserScrollSettle();
const release = readThreadFeedEndState(input.listRef.current?.getState());
input.userScrollSettleTimerRef.current = setTimeout(() => input.finishUserScroll(release), 160);
}

/** After disclosure row resize, report exact-end and reconcile live-follow. */
function settleThreadFeedDisclosureAfterLayout(input: {
readonly listRef: RefObject<LegendListRef | null>;
readonly userScrollSessionRef: { current: boolean };
readonly disclosureSettleFrameRef: { current: number | null };
readonly disclosureSettleSecondFrameRef: { current: number | null };
readonly disclosureAnchorKeyRef: { current: string | null };
readonly onIsAtEndChange?: (isAtEnd: boolean) => void;
readonly transitionEndFollow: (event: ThreadFeedLiveFollowEvent) => void;
readonly setDisclosureToggleSettling: (value: boolean) => void;
}) {
if (input.disclosureSettleFrameRef.current !== null) {
cancelAnimationFrame(input.disclosureSettleFrameRef.current);
}
if (input.disclosureSettleSecondFrameRef.current !== null) {
cancelAnimationFrame(input.disclosureSettleSecondFrameRef.current);
}
input.disclosureSettleFrameRef.current = requestAnimationFrame(() => {
input.disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = input.listRef.current?.getState();
if (listState) {
// Row resizing can change the end without notifying the edge subscription.
input.onIsAtEndChange?.(listState.isAtEnd);
input.transitionEndFollow({
type: "disclosure-settled",
isAtEnd: listState.isAtEnd,
userScrollSessionActive: input.userScrollSessionRef.current,
});
}
input.disclosureAnchorKeyRef.current = null;
input.setDisclosureToggleSettling(false);
input.disclosureSettleFrameRef.current = null;
input.disclosureSettleSecondFrameRef.current = null;
});
});
}

/** Empty-state placeholder shown while the transcript has nothing to virtualize. */
function ThreadFeedPlaceholder(props: {
readonly bottomInset: number;
readonly detail: string;
Expand Down Expand Up @@ -1947,7 +2091,8 @@ function ThreadFeedPlaceholder(props: {
);
}

export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
/** Virtualized transcript that reports live-edge position for the scroll-to-end control. */
function ThreadFeedView(props: ThreadFeedProps) {
const navigation = useNavigation();
const { themeAppearance } = useAppearancePreferences();
const copyFeedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -2327,27 +2472,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
},
[props.onHeaderMaterialVisibilityChange],
);
/** Re-arm live-follow from LegendList exact-end and near-end flags while scrolling. */
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
// anchorTopInset, not topContentInset: under automatic insets the list
// rests at contentOffset.y = -headerHeight (the inset lives only in
// UIKit's adjustedContentInset, so topContentInset is 0 here). Add the
// header height back or the material toggles a full header too late.
reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6);
// LegendList recomputes its inset-aware end distance before invoking
// this handler, so getState() is current. Only the actual end re-arms
// follow: its broader maintain-scroll threshold is large enough for a
// streaming chunk to pull a user back before their upward drag escapes.
// A live user-scroll session still wins even if the first scroll event
// remains inside LegendList's at-end tolerance.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "scroll",
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
handleThreadFeedScroll({
event,
listRef: props.listRef,
userScrollSessionRef,
anchorTopInset,
reportHeaderMaterialVisibility,
transitionEndFollow,
});
},
[reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow],
);
Expand All @@ -2364,18 +2499,15 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// maintainScrollAtEnd between touch-down and the drag leaving its threshold.
transitionEndFollow({ type: "user-scroll-begin" });
}, [clearUserScrollSettle, transitionEndFollow]);
/** End a user-scroll session using the finger-release end position, not later stream growth. */
const finishUserScroll = useCallback(
(releaseIsAtEnd?: boolean) => {
clearUserScrollSettle();
const userScrollSessionActive = userScrollSessionRef.current;
userScrollSessionRef.current = false;
transitionEndFollow({
type: "user-scroll-end",
// With no momentum, preserve the finger-release position. Streaming
// growth during the native momentum-detection window must not turn a
// release at the live edge into an opt-out from follow.
isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false,
userScrollSessionActive,
(release?: { readonly isAtEnd: boolean; readonly nearEnd: boolean }) => {
finishThreadFeedUserScroll({
release,
listRef: props.listRef,
userScrollSessionRef,
clearUserScrollSettle,
transitionEndFollow,
});
},
[clearUserScrollSettle, props.listRef, transitionEndFollow],
Expand All @@ -2385,10 +2517,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// to announce itself; if it does, onMomentumScrollBegin cancels this fallback
// and the session survives until the settled momentum-end position. This
// mirrors the native-event handoff used by the home thread list's scroll gate.
/** Snapshot the finger-release end position in case native momentum never starts. */
const handleScrollEndDrag = useCallback(() => {
clearUserScrollSettle();
const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false;
userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160);
scheduleThreadFeedUserScrollFinish({
listRef: props.listRef,
userScrollSettleTimerRef,
finishUserScroll,
clearUserScrollSettle,
});
}, [clearUserScrollSettle, finishUserScroll, props.listRef]);
const handleMomentumScrollBegin = useCallback(() => {
if (userScrollSessionRef.current) {
Expand Down Expand Up @@ -2485,6 +2621,12 @@ 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(
() => subscribeThreadFeedIsAtEnd(props.listRef, props.onIsAtEndChange),
[listMountKey, props.listRef, props.onIsAtEndChange],
);

const anchoredEndSpace = useMemo(
() =>
resolveChatListAnchoredEndSpace(
Expand Down Expand Up @@ -2544,32 +2686,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
};
}, []);

/** After disclosure row resize, report exact-end and reconcile live-follow. */
const settleDisclosureAfterLayout = useCallback(() => {
if (disclosureSettleFrameRef.current !== null) {
cancelAnimationFrame(disclosureSettleFrameRef.current);
}
if (disclosureSettleSecondFrameRef.current !== null) {
cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
}
disclosureSettleFrameRef.current = requestAnimationFrame(() => {
disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
// A disclosure can leave the reader above the end without a drag.
// Reconcile follow before a later layout or resume can re-pin it.
const listState = props.listRef.current?.getState();
if (listState) {
transitionEndFollow({
type: "disclosure-settled",
isAtEnd: listState.isAtEnd,
userScrollSessionActive: userScrollSessionRef.current,
});
}
disclosureAnchorKeyRef.current = null;
setDisclosureToggleSettling(false);
disclosureSettleFrameRef.current = null;
disclosureSettleSecondFrameRef.current = null;
});
settleThreadFeedDisclosureAfterLayout({
listRef: props.listRef,
userScrollSessionRef,
disclosureSettleFrameRef,
disclosureSettleSecondFrameRef,
disclosureAnchorKeyRef,
onIsAtEndChange: props.onIsAtEndChange,
transitionEndFollow,
setDisclosureToggleSettling,
});
}, [props.listRef, transitionEndFollow]);
}, [props.listRef, props.onIsAtEndChange, transitionEndFollow]);

const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string | null) => {
disclosureAnchorKeyRef.current = anchorKey;
Expand Down Expand Up @@ -3013,4 +3142,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
</View>
</PresentationSource>
);
});
}

export const ThreadFeed = memo(ThreadFeedView);
ThreadFeed.displayName = "ThreadFeed";
Loading
Loading