Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
103 changes: 97 additions & 6 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
FadeIn,
FadeInUp,
LinearTransition,
useSharedValue,
withTiming,
type LayoutAnimationsValues,
type SharedValue,
} from "react-native-reanimated";
import { useThemeColor } from "../../lib/useThemeColor";
Expand Down Expand Up @@ -91,7 +93,12 @@ import {
type ThreadFeedLatestTurn,
} from "../../lib/threadActivity";
import type { ThreadContentPresentation } from "./threadContentPresentation";
import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log";
import {
collapsedWorkLogHeight,
ThreadWorkGroupToggle,
ThreadWorkLog,
WORK_GROUP_TOGGLE_HEIGHT,
} from "./thread-work-log";
import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState";
import { useAssetUrl } from "../../state/assets";
import { resolveWorkspaceRelativeFilePath } from "../files/filePath";
Expand All @@ -109,8 +116,19 @@ function formatMessageTime(input: string): string {
}

// Rows shift when content above them grows (streaming text, work-log folds);
// animating the container position turns those jumps into slides.
const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180);
// animating the container position turns those jumps into slides. Applied
// conditionally — see the gated transition in ThreadFeed: while browsing
// history the animation must NOT run, or every estimate→actual size
// correction plays as a visible slide against the instant scroll-offset
// compensation from maintainVisibleContentPosition.
const FEED_ITEM_LAYOUT_DURATION_MS = 180;

// Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's
// classNames. Both rows hold a single text line shorter than the fixed parts,
// so the height is deterministic; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
const WORKING_ROW_HEIGHT = 40; // py-1 (8) + text-xs line (16) + mb-4 (16)

// Entering animations must only play for rows born just now — LegendList
// remounts rows when they scroll back into view, and replaying an entrance for
Expand Down Expand Up @@ -1410,16 +1428,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
},
[props.onHeaderMaterialVisibilityChange],
);
// True while the viewport sits within ~one screen of the list end — the
// only region where layout shifts should animate. Starts true because the
// list opens pinned to the end.
const nearListEnd = useSharedValue(true);

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);
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
nearListEnd.value =
contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height;
},
[reportHeaderMaterialVisibility, anchorTopInset],
[reportHeaderMaterialVisibility, anchorTopInset, nearListEnd],
);

// Gated variant of the 180ms feed layout slide. Instant while browsing
// history: maintainVisibleContentPosition compensates the scroll offset in
// the same frame a row's measured size lands, so an instant reposition is
// invisible — animating it is exactly what made cold upward scrolls slide
// and jump. Near the end the slide stays on: streaming growth and sends
// shift rows at rest, where the animation is the thing preventing a hard
// visual snap.
const feedItemLayoutTransition = useMemo(() => {
return (values: LayoutAnimationsValues) => {
"worklet";
const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0;
return {
initialValues: {
originX: values.currentOriginX,
originY: values.currentOriginY,
width: values.currentWidth,
height: values.currentHeight,
},
animations: {
originX: withTiming(values.targetOriginX, { duration }),
originY: withTiming(values.targetOriginY, { duration }),
width: withTiming(values.targetWidth, { duration }),
height: withTiming(values.targetHeight, { duration }),
},
};
};
}, [nearListEnd]);
const handleViewportLayout = useCallback((event: LayoutChangeEvent) => {
const nextWidth = Math.round(event.nativeEvent.layout.width);
const nextHeight = Math.round(event.nativeEvent.layout.height);
Expand Down Expand Up @@ -1636,6 +1690,34 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
setExpandedImage({ uri, headers });
}, []);

// Rows whose height is known before they ever render. Without this, every
// row above the viewport is assumed to be estimatedItemSize tall, and
// scrolling up through unmeasured content corrects each row's height as it
// mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
// exact; message rows stay undefined and use LegendList's per-type running
// average once one of their type has been measured.
const getFixedItemSize = useCallback(
(entry: ThreadFeedEntry) => {
switch (entry.type) {
case "turn-fold":
return TURN_FOLD_HEIGHT;
case "work-toggle":
return WORK_GROUP_TOGGLE_HEIGHT;
case "working":
return WORKING_ROW_HEIGHT;
case "activity-group":
// Expanded rows append a variable detail block — fall back to
// measurement for those groups.
return entry.activities.some((activity) => expandedWorkRows[activity.id])
? undefined
: collapsedWorkLogHeight(entry.activities);
default:
return undefined;
}
},
[expandedWorkRows],
);

const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) =>
renderFeedEntry(info, {
Expand Down Expand Up @@ -1725,7 +1807,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
}
: { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })}
{...(anchoredEndSpace ? { anchoredEndSpace } : {})}
itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION}
itemLayoutAnimation={feedItemLayoutTransition}
// Patched LegendList prop (patches/@legendapp__list@3.2.0.patch):
// lets its scroll math clamp programmatic scrolls to -headerInset
// instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
Expand Down Expand Up @@ -1769,6 +1851,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
getItemType={(entry) =>
entry.type === "message" ? `message:${entry.message.role}` : entry.type
}
getFixedItemSize={getFixedItemSize}
// Measure rows well before they scroll into view so estimate→actual
// corrections land offscreen instead of under the user's finger.
drawDistance={500}
keyboardShouldPersistTaps="always"
keyboardDismissMode="none"
keyboardLiftBehavior="whenAtEnd"
Expand All @@ -1788,6 +1874,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
// user overscroll back to the adjusted rest position.
scrollToOverflowEnabled
estimatedItemSize={180}
// Chat-style bottom alignment: when a thread is shorter than the
// viewport, pad above the content so messages rest just above the
// composer instead of under the header. No effect on threads that
// overflow the viewport (the padding clamps to zero).
alignItemsAtEnd
initialScrollAtEnd
onScroll={handleScroll}
scrollEventThrottle={16}
Expand Down
39 changes: 36 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,38 @@ function isFreshRow(createdAt: string): boolean {
return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS;
}

// Tool-like activities with a neutral status carry no signal worth a row.
export function visibleWorkLogActivities(
activities: ReadonlyArray<ThreadFeedActivity>,
): ReadonlyArray<ThreadFeedActivity> {
return activities.filter((activity) => !(activity.toolLike && activity.status === "neutral"));
}

// Pre-measurement heights for the feed's getFixedItemSize. Collapsed work-log
// rows are single-line (numberOfLines={1}) inside a min-height taller than the
// text, so their height is deterministic. Values mirror the classNames below —
// keep them in sync; a mismatch only costs a one-time correction on measure.
const WORK_ROW_HEIGHT = 32; // min-h-8
const WORK_ROW_GAP = 1; // gap-px
const WORK_LOG_HEADER_HEIGHT = 18; // "work log" label: text-2xs line (16) + pb-0.5 (2)
const WORK_LOG_BOTTOM_MARGIN = 4; // mb-1

export const WORK_GROUP_TOGGLE_HEIGHT = 36; // min-h-8 (32) + mb-1 (4)

export function collapsedWorkLogHeight(activities: ReadonlyArray<ThreadFeedActivity>): number {
const rows = visibleWorkLogActivities(activities);
if (rows.length === 0) {
return 0;
}
const onlyToolRows = rows.every((row) => row.toolLike);
return (
WORK_LOG_BOTTOM_MARGIN +
(onlyToolRows ? 0 : WORK_LOG_HEADER_HEIGHT) +
rows.length * WORK_ROW_HEIGHT +
(rows.length - 1) * WORK_ROW_GAP
);
}

export function ThreadWorkLog(props: {
readonly activities: ReadonlyArray<ThreadFeedActivity>;
readonly copiedRowId: string | null;
Expand All @@ -87,9 +119,10 @@ export function ThreadWorkLog(props: {
}) {
const colorScheme = useColorScheme();
const pressedBackground = colorScheme === "dark" ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.035)";
const rows = props.activities
.filter((activity) => !(activity.toolLike && activity.status === "neutral"))
.map((activity) => ({ ...activity, detail: compactActivityDetail(activity.detail) }));
const rows = visibleWorkLogActivities(props.activities).map((activity) => ({
...activity,
detail: compactActivityDetail(activity.detail),
}));

if (rows.length === 0) {
return null;
Expand Down
Loading