Skip to content
Merged
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
113 changes: 107 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 @@ -80,7 +82,9 @@ import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../.
import {
resolveMarkdownFontSizes,
resolveNativeMarkdownTypography,
scaledTypographyLineHeight,
} from "../../lib/appearancePreferences";
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface";
import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons";
Expand All @@ -91,7 +95,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 +118,22 @@ 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. The fold row's min-h-11 (44px) stays taller than its single
// text-sm line at every supported base font size (26px at the 22pt maximum),
// so its height is a constant; a drifted value costs one correction on
// measure, not a persistent offset.
const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12)
// The working row has no min-height clamp — its height follows the scaled
// text-xs line height (see workingRowHeight in ThreadFeed).
const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + 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 @@ -1301,6 +1324,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const headerMaterialVisibleRef = useRef(false);
const previousLatestTurnRef = useRef(props.latestTurn);
const { width: windowWidth } = useWindowDimensions();
const { appearance } = useAppearancePreferences();
const [viewportWidth, setViewportWidth] = useState(() =>
props.layoutVariant === "split" ? 0 : windowWidth,
);
Expand Down Expand Up @@ -1410,16 +1434,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 +1696,38 @@ 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. Text-driven heights
// follow the configurable base font size via scaledTypographyLineHeight.
const workingRowHeight =
WORKING_ROW_VERTICAL_EXTRAS +
scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize);
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 workingRowHeight;
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, appearance.baseFontSize);
default:
return undefined;
}
},
[expandedWorkRows, workingRowHeight, appearance.baseFontSize],
);

const renderItem = useCallback(
(info: { item: ThreadFeedEntry; index: number }) =>
renderFeedEntry(info, {
Expand Down Expand Up @@ -1725,7 +1817,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 +1861,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 +1884,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
49 changes: 46 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { type AppSymbolName, SymbolView } from "../../components/AppSymbol";
import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native";

import { AppText as Text } from "../../components/AppText";
import { scaledTypographyLineHeight } from "../../lib/appearancePreferences";
import { cn } from "../../lib/cn";
import type { ThreadFeedActivity } from "../../lib/threadActivity";
import { MOBILE_TYPOGRAPHY } from "../../lib/typography";
import Animated, { FadeIn } from "react-native-reanimated";

const WORK_LOG_LAYOUT_ANIMATION = {
Expand Down Expand Up @@ -77,6 +79,46 @@ 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 that stays
// taller than the text at every supported base font size (text-xs reaches
// 23px at the 22pt maximum, under the 32px min-h-8), so row height is
// deterministic. The "work log" label has no such clamp — its height follows
// the scaled text-2xs line height. 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_PADDING = 2; // pb-0.5 under the "work log" label
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>,
baseFontSize: number,
): number {
const rows = visibleWorkLogActivities(activities);
if (rows.length === 0) {
return 0;
}
const onlyToolRows = rows.every((row) => row.toolLike);
const headerHeight =
scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.caption, baseFontSize) + WORK_LOG_HEADER_PADDING;
return (
WORK_LOG_BOTTOM_MARGIN +
(onlyToolRows ? 0 : headerHeight) +
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 +129,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
17 changes: 16 additions & 1 deletion apps/mobile/src/lib/appearancePreferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,27 @@ export function resolveTextScaleVariables(baseFontSize: number): Record<string,

for (const [name, role] of Object.entries(TEXT_SCALE_VARIABLE_ROLES)) {
variables[name] = Math.max(8, Math.round(role.fontSize * scale));
variables[`${name}--line-height`] = Math.max(10, Math.round(role.lineHeight * scale));
variables[`${name}--line-height`] = scaledTypographyLineHeight(role, baseFontSize);
}

return variables;
}

/**
* The line height a MOBILE_TYPOGRAPHY role renders at under the given base
* font size — the same value resolveTextScaleVariables injects for the role's
* `--text-*--line-height` variable. For layout code that must predict
* text-driven heights (e.g. the thread feed's fixed item sizes) instead of
* measuring them.
*/
export function scaledTypographyLineHeight(
role: { readonly lineHeight: number },
baseFontSize: number,
): number {
const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE;
return Math.max(10, Math.round(role.lineHeight * scale));
}

export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMarkdownTypography {
const fontSizes = resolveMarkdownFontSizes(baseFontSize);
return {
Expand Down
Loading