| ... | ... | @@ -48,7 +48,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 48 | 48 | import Animated, { |
| 49 | 49 | FadeIn, |
| 50 | 50 | FadeInUp, |
| 51 | | LinearTransition, |
| 51 | useSharedValue, |
| 52 | withTiming, |
| 53 | type LayoutAnimationsValues, |
| 52 | 54 | type SharedValue, |
| 53 | 55 | } from "react-native-reanimated"; |
| 54 | 56 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | ... | @@ -80,7 +82,9 @@ import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../. |
| 80 | 82 | import { |
| 81 | 83 | resolveMarkdownFontSizes, |
| 82 | 84 | resolveNativeMarkdownTypography, |
| 85 | scaledTypographyLineHeight, |
| 83 | 86 | } from "../../lib/appearancePreferences"; |
| 87 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 84 | 88 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 85 | 89 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 86 | 90 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| ... | ... | @@ -91,7 +95,12 @@ import { |
| 91 | 95 | type ThreadFeedLatestTurn, |
| 92 | 96 | } from "../../lib/threadActivity"; |
| 93 | 97 | import type { ThreadContentPresentation } from "./threadContentPresentation"; |
| 94 | | import { ThreadWorkGroupToggle, ThreadWorkLog } from "./thread-work-log"; |
| 98 | import { |
| 99 | collapsedWorkLogHeight, |
| 100 | ThreadWorkGroupToggle, |
| 101 | ThreadWorkLog, |
| 102 | WORK_GROUP_TOGGLE_HEIGHT, |
| 103 | } from "./thread-work-log"; |
| 95 | 104 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 96 | 105 | import { useAssetUrl } from "../../state/assets"; |
| 97 | 106 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| ... | ... | @@ -109,8 +118,22 @@ function formatMessageTime(input: string): string { |
| 109 | 118 | } |
| 110 | 119 | |
| 111 | 120 | // Rows shift when content above them grows (streaming text, work-log folds); |
| 112 | | // animating the container position turns those jumps into slides. |
| 113 | | const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180); |
| 121 | // animating the container position turns those jumps into slides. Applied |
| 122 | // conditionally — see the gated transition in ThreadFeed: while browsing |
| 123 | // history the animation must NOT run, or every estimate→actual size |
| 124 | // correction plays as a visible slide against the instant scroll-offset |
| 125 | // compensation from maintainVisibleContentPosition. |
| 126 | const FEED_ITEM_LAYOUT_DURATION_MS = 180; |
| 127 | |
| 128 | // Pre-measurement heights for getFixedItemSize, mirroring renderFeedEntry's |
| 129 | // classNames. The fold row's min-h-11 (44px) stays taller than its single |
| 130 | // text-sm line at every supported base font size (26px at the 22pt maximum), |
| 131 | // so its height is a constant; a drifted value costs one correction on |
| 132 | // measure, not a persistent offset. |
| 133 | const TURN_FOLD_HEIGHT = 56; // min-h-11 (44) + mb-3 (12) |
| 134 | // The working row has no min-height clamp — its height follows the scaled |
| 135 | // text-xs line height (see workingRowHeight in ThreadFeed). |
| 136 | const WORKING_ROW_VERTICAL_EXTRAS = 24; // py-1 (8) + mb-4 (16) |
| 114 | 137 | |
| 115 | 138 | // Entering animations must only play for rows born just now — LegendList |
| 116 | 139 | // remounts rows when they scroll back into view, and replaying an entrance for |
| ... | ... | @@ -1301,6 +1324,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1301 | 1324 | const headerMaterialVisibleRef = useRef(false); |
| 1302 | 1325 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1303 | 1326 | const { width: windowWidth } = useWindowDimensions(); |
| 1327 | const { appearance } = useAppearancePreferences(); |
| 1304 | 1328 | const [viewportWidth, setViewportWidth] = useState(() => |
| 1305 | 1329 | props.layoutVariant === "split" ? 0 : windowWidth, |
| 1306 | 1330 | ); |
| ... | ... | @@ -1410,6 +1434,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1410 | 1434 | }, |
| 1411 | 1435 | [props.onHeaderMaterialVisibilityChange], |
| 1412 | 1436 | ); |
| 1437 | // True while the viewport sits within ~one screen of the list end — the |
| 1438 | // only region where layout shifts should animate. Starts true because the |
| 1439 | // list opens pinned to the end. |
| 1440 | const nearListEnd = useSharedValue(true); |
| 1441 | |
| 1413 | 1442 | const handleScroll = useCallback( |
| 1414 | 1443 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { |
| 1415 | 1444 | // anchorTopInset, not topContentInset: under automatic insets the list |
| ... | ... | @@ -1417,9 +1446,40 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1417 | 1446 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the |
| 1418 | 1447 | // header height back or the material toggles a full header too late. |
| 1419 | 1448 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); |
| 1449 | const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; |
| 1450 | nearListEnd.value = |
| 1451 | contentSize.height - layoutMeasurement.height - contentOffset.y < layoutMeasurement.height; |
| 1420 | 1452 | }, |
| 1421 | | [reportHeaderMaterialVisibility, anchorTopInset], |
| 1453 | [reportHeaderMaterialVisibility, anchorTopInset, nearListEnd], |
| 1422 | 1454 | ); |
| 1455 | |
| 1456 | // Gated variant of the 180ms feed layout slide. Instant while browsing |
| 1457 | // history: maintainVisibleContentPosition compensates the scroll offset in |
| 1458 | // the same frame a row's measured size lands, so an instant reposition is |
| 1459 | // invisible — animating it is exactly what made cold upward scrolls slide |
| 1460 | // and jump. Near the end the slide stays on: streaming growth and sends |
| 1461 | // shift rows at rest, where the animation is the thing preventing a hard |
| 1462 | // visual snap. |
| 1463 | const feedItemLayoutTransition = useMemo(() => { |
| 1464 | return (values: LayoutAnimationsValues) => { |
| 1465 | "worklet"; |
| 1466 | const duration = nearListEnd.value ? FEED_ITEM_LAYOUT_DURATION_MS : 0; |
| 1467 | return { |
| 1468 | initialValues: { |
| 1469 | originX: values.currentOriginX, |
| 1470 | originY: values.currentOriginY, |
| 1471 | width: values.currentWidth, |
| 1472 | height: values.currentHeight, |
| 1473 | }, |
| 1474 | animations: { |
| 1475 | originX: withTiming(values.targetOriginX, { duration }), |
| 1476 | originY: withTiming(values.targetOriginY, { duration }), |
| 1477 | width: withTiming(values.targetWidth, { duration }), |
| 1478 | height: withTiming(values.targetHeight, { duration }), |
| 1479 | }, |
| 1480 | }; |
| 1481 | }; |
| 1482 | }, [nearListEnd]); |
| 1423 | 1483 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { |
| 1424 | 1484 | const nextWidth = Math.round(event.nativeEvent.layout.width); |
| 1425 | 1485 | const nextHeight = Math.round(event.nativeEvent.layout.height); |
| ... | ... | @@ -1636,6 +1696,38 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1636 | 1696 | setExpandedImage({ uri, headers }); |
| 1637 | 1697 | }, []); |
| 1638 | 1698 | |
| 1699 | // Rows whose height is known before they ever render. Without this, every |
| 1700 | // row above the viewport is assumed to be estimatedItemSize tall, and |
| 1701 | // scrolling up through unmeasured content corrects each row's height as it |
| 1702 | // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows |
| 1703 | // exact; message rows stay undefined and use LegendList's per-type running |
| 1704 | // average once one of their type has been measured. Text-driven heights |
| 1705 | // follow the configurable base font size via scaledTypographyLineHeight. |
| 1706 | const workingRowHeight = |
| 1707 | WORKING_ROW_VERTICAL_EXTRAS + |
| 1708 | scaledTypographyLineHeight(MOBILE_TYPOGRAPHY.label, appearance.baseFontSize); |
| 1709 | const getFixedItemSize = useCallback( |
| 1710 | (entry: ThreadFeedEntry) => { |
| 1711 | switch (entry.type) { |
| 1712 | case "turn-fold": |
| 1713 | return TURN_FOLD_HEIGHT; |
| 1714 | case "work-toggle": |
| 1715 | return WORK_GROUP_TOGGLE_HEIGHT; |
| 1716 | case "working": |
| 1717 | return workingRowHeight; |
| 1718 | case "activity-group": |
| 1719 | // Expanded rows append a variable detail block — fall back to |
| 1720 | // measurement for those groups. |
| 1721 | return entry.activities.some((activity) => expandedWorkRows[activity.id]) |
| 1722 | ? undefined |
| 1723 | : collapsedWorkLogHeight(entry.activities, appearance.baseFontSize); |
| 1724 | default: |
| 1725 | return undefined; |
| 1726 | } |
| 1727 | }, |
| 1728 | [expandedWorkRows, workingRowHeight, appearance.baseFontSize], |
| 1729 | ); |
| 1730 | |
| 1639 | 1731 | const renderItem = useCallback( |
| 1640 | 1732 | (info: { item: ThreadFeedEntry; index: number }) => |
| 1641 | 1733 | renderFeedEntry(info, { |
| ... | ... | @@ -1725,7 +1817,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1725 | 1817 | } |
| 1726 | 1818 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} |
| 1727 | 1819 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 1728 | | itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION} |
| 1820 | itemLayoutAnimation={feedItemLayoutTransition} |
| 1729 | 1821 | // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): |
| 1730 | 1822 | // lets its scroll math clamp programmatic scrolls to -headerInset |
| 1731 | 1823 | // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short |
| ... | ... | @@ -1769,6 +1861,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1769 | 1861 | getItemType={(entry) => |
| 1770 | 1862 | entry.type === "message" ? `message:${entry.message.role}` : entry.type |
| 1771 | 1863 | } |
| 1864 | getFixedItemSize={getFixedItemSize} |
| 1865 | // Measure rows well before they scroll into view so estimate→actual |
| 1866 | // corrections land offscreen instead of under the user's finger. |
| 1867 | drawDistance={500} |
| 1772 | 1868 | keyboardShouldPersistTaps="always" |
| 1773 | 1869 | keyboardDismissMode="none" |
| 1774 | 1870 | keyboardLiftBehavior="whenAtEnd" |
| ... | ... | @@ -1788,6 +1884,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1788 | 1884 | // user overscroll back to the adjusted rest position. |
| 1789 | 1885 | scrollToOverflowEnabled |
| 1790 | 1886 | estimatedItemSize={180} |
| 1887 | // Chat-style bottom alignment: when a thread is shorter than the |
| 1888 | // viewport, pad above the content so messages rest just above the |
| 1889 | // composer instead of under the header. No effect on threads that |
| 1890 | // overflow the viewport (the padding clamps to zero). |
| 1891 | alignItemsAtEnd |
| 1791 | 1892 | initialScrollAtEnd |
| 1792 | 1893 | onScroll={handleScroll} |
| 1793 | 1894 | scrollEventThrottle={16} |