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
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export const COMPOSER_COLLAPSED_CHROME = 60;
*/
export const COMPOSER_EXPANDED_CHROME = 174;

export const COMPOSER_PENDING_CARD_MIN_CHROME = 160;

export interface ThreadComposerProps {
readonly draftMessage: string;
readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
Expand Down
36 changes: 32 additions & 4 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import type {
} from "@t3tools/contracts";
import * as Haptics from "expo-haptics";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Platform, View, type GestureResponderEvent } from "react-native";
import {
Platform,
View,
type GestureResponderEvent,
type LayoutChangeEvent,
} from "react-native";
import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
import Animated, { FadeInDown, FadeOut } from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
Expand All @@ -37,6 +42,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard";
import {
COMPOSER_COLLAPSED_CHROME,
COMPOSER_EXPANDED_CHROME,
COMPOSER_PENDING_CARD_MIN_CHROME,
ThreadComposer,
} from "./ThreadComposer";
import { ThreadFeed } from "./ThreadFeed";
Expand Down Expand Up @@ -180,7 +186,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
const selectedThreadKeyRef = useRef(selectedThreadKey);
const lastScrolledAnchorMessageIdRef = useRef<MessageId | null>(null);
const [composerExpanded, setComposerExpanded] = useState(false);
const composerExpandedRef = useRef(false);
const handleComposerExpandedChange = useCallback((expanded: boolean) => {
composerExpandedRef.current = expanded;
setComposerExpanded(expanded);
}, []);
const [anchorMessageId, setAnchorMessageId] = useState<MessageId | null>(null);
const [composerMeasuredHeight, setComposerMeasuredHeight] = useState<number | null>(null);
Comment thread
cursor[bot] marked this conversation as resolved.
const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12);
const contentPresentationKind = props.contentPresentation.kind;
// The raw sync status enters "synchronizing" on every full fetch, cached or
Expand All @@ -201,7 +213,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
})();
const selectedThreadFeed = props.selectedThreadFeed;
const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME;
const composerOverlapHeight = composerChrome + composerBottomInset;
const composerExtraChrome =
((props.activePendingApproval === null ? 0 : 1) +
(props.activePendingUserInput === null ? 0 : 1)) *
COMPOSER_PENDING_CARD_MIN_CHROME;
const composerOverlapHeight = composerChrome + composerExtraChrome + composerBottomInset;
const estimatedOverlayHeight = composerOverlapHeight;
// The overlay's measured height includes the home-indicator inset (the
// composer pads it), but contentInsetAdjustmentBehavior="automatic" makes
Expand All @@ -218,6 +234,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
Math.max(0, estimatedOverlayHeight - nativeInsetOvercount),
-nativeInsetOvercount,
);
const handleComposerOverlayLayout = useCallback(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

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.

🟠 High threads/ThreadDetailScreen.tsx:237

When the composer is focused (expanded) and the feed list remounts — e.g. after a pending-approval or pending-user-input card appears or the empty→filled transition fires — ThreadFeed's corrective scrollToEnd effect never runs. The list stays positioned with a stale bottom inset and rests one composer-height short of the end. handleComposerOverlayLayout skips updating composerMeasuredHeight whenever composerExpandedRef.current is true, so the new overlay measurement (which the pending cards make larger) does not trigger the effect's props.composerMeasuredHeight dependency. Consider updating composerMeasuredHeight on every layout, or using the overlay's measured height from onComposerLayout as the effect dependency instead of the gated value.

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

When the composer is focused (expanded) and the feed list remounts — e.g. after a pending-approval or pending-user-input card appears or the empty→filled transition fires — `ThreadFeed`'s corrective `scrollToEnd` effect never runs. The list stays positioned with a stale bottom inset and rests one composer-height short of the end. `handleComposerOverlayLayout` skips updating `composerMeasuredHeight` whenever `composerExpandedRef.current` is true, so the new overlay measurement (which the pending cards make larger) does not trigger the effect's `props.composerMeasuredHeight` dependency. Consider updating `composerMeasuredHeight` on every layout, or using the overlay's measured height from `onComposerLayout` as the effect dependency instead of the gated value.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Pushing back on this one — both premises do not hold, and the suggested fix (mirror on every layout) is the exact code that caused a reproduced-on-device bug. (1) The gate only skips the React-state mirror; onComposerLayout still runs on every layout, so contentInsetEndAdjustment is always current and a remounting list primes from the live measurement in the listMountKey layout effect — there is no stale-inset remount. (2) While the composer is expanded the keyboard is up, and the keyboard integration reacts to that same shared value (useExtraContentPadding), inset- and scroll-compensating overlay growth natively. Mirroring during that window made the corrective effect reportContentInset the resting overlay height while the keyboard inset was active, which pinned the feed under the composer on a real device — that regression is why the gate exists. Any residue heals on collapse: the collapse onLayout mirrors the final height and the correction runs with the keyboard closed.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

(event: LayoutChangeEvent) => {
onComposerLayout(event);
// expanded = keyboard transition, which manages its own inset
if (!composerExpandedRef.current) {
setComposerMeasuredHeight(event.nativeEvent.layout.height);
}
},
[onComposerLayout],
);
const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef });
const showContent = props.showContent ?? true;
const layoutVariant = props.layoutVariant ?? "compact";
Expand All @@ -240,6 +266,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
setAnchorMessageId(null);
lastScrolledAnchorMessageIdRef.current = null;
freeze.set(false);
setComposerMeasuredHeight(null);
}, [freeze, selectedThreadKey]);

useEffect(() => {
Expand Down Expand Up @@ -364,6 +391,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
freeze={freeze}
anchorMessageId={anchorMessageId}
contentInsetEndAdjustment={contentInsetEndAdjustment}
composerMeasuredHeight={composerMeasuredHeight}
contentTopInset={0}
contentBottomInset={estimatedOverlayHeight}
contentMaxWidth={contentMaxWidth}
Expand All @@ -386,7 +414,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
{/* No paddingTop here: the overlay's measured height becomes the
list's bottom inset, so any padding above the pill/composer
pushes the resting content floor up by the same amount. */}
<View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
<View ref={composerOverlayRef} onLayout={handleComposerOverlayLayout} className="w-full">
<View className="w-full self-center" style={{ maxWidth: contentMaxWidth }}>
{props.activePendingApproval || props.activePendingUserInput ? (
<Animated.View
Expand Down Expand Up @@ -443,7 +471,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
onUpdateModelSelection={props.onUpdateThreadModelSelection}
onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
onExpandedChange={setComposerExpanded}
onExpandedChange={handleComposerExpandedChange}
/>
</View>
</KeyboardStickyView>
Expand Down
29 changes: 26 additions & 3 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export interface ThreadFeedProps {
readonly freeze: SharedValue<boolean>;
readonly anchorMessageId: MessageId | null;
readonly contentInsetEndAdjustment: SharedValue<number>;
readonly composerMeasuredHeight?: number | null;
readonly contentTopInset?: number;
readonly contentBottomInset?: number;
readonly contentMaxWidth?: number;
Expand Down Expand Up @@ -1438,6 +1439,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// only region where layout shifts should animate. Starts true because the
// list opens pinned to the end.
const nearListEnd = useSharedValue(true);
const userDraggedSinceMountRef = useRef(false);
const handleScrollBeginDrag = useCallback(() => {
userDraggedSinceMountRef.current = true;
}, []);

const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
Expand Down Expand Up @@ -1526,12 +1531,29 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// composer-height short of the end. Layout effect: it must land before the
// list's first positioning tick or the one-shot initial scroll misses it.
const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`;
const lastReprimedMountKeyRef = useRef<string | null>(null);
useLayoutEffect(() => {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const bottom = props.contentInsetEndAdjustment.value;
if (bottom > 0) {
props.listRef.current?.reportContentInset({ bottom });
const isInitialMountForKey = lastReprimedMountKeyRef.current !== listMountKey;
lastReprimedMountKeyRef.current = listMountKey;
if (isInitialMountForKey) {
nearListEnd.value = true;
userDraggedSinceMountRef.current = false;
}
if (bottom <= 0) {
return;
}
}, [listMountKey, props.contentInsetEndAdjustment, props.listRef]);
props.listRef.current?.reportContentInset({ bottom });
if (!isInitialMountForKey && nearListEnd.value && !userDraggedSinceMountRef.current) {
props.listRef.current?.scrollToEnd({ animated: true });
Comment thread
cursor[bot] marked this conversation as resolved.

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.

Inset correction ignores send anchor

Medium Severity

After a send, ThreadDetailScreen keeps anchorMessageId set and drives anchoredEndSpace so the list pins the sent message, but the new post-mount inset effect still calls scrollToEnd when composerMeasuredHeight changes. That scroll targets the raw list end, not the anchor, so composer remeasurement (keyboard collapse, pending cards, etc.) can override the anchor scroll while the user is still near the bottom.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0a62ad9. Configure here.

}
}, [
listMountKey,
props.composerMeasuredHeight,
props.contentInsetEndAdjustment,
props.listRef,
nearListEnd,
]);

const anchoredEndSpace = useMemo(
() =>
Expand Down Expand Up @@ -1891,6 +1913,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
alignItemsAtEnd
initialScrollAtEnd
onScroll={handleScroll}
onScrollBeginDrag={handleScrollBeginDrag}
scrollEventThrottle={16}
ListHeaderComponent={
usesNativeAutomaticInsets ? null : <View style={{ height: topContentInset }} />
Expand Down
Loading