| ... | @@ -48,7 +48,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; | ... | @@ -48,7 +48,9 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 48 | import Animated, { | 48 | import Animated, { |
| 49 | FadeIn, | 49 | FadeIn, |
| 50 | FadeInUp, | 50 | FadeInUp, |
| 51 | LinearTransition, | 51 | useSharedValue, |
| | 52 | withTiming, |
| | 53 | type LayoutAnimationsValues, |
| 52 | type SharedValue, | 54 | type SharedValue, |
| 53 | } from "react-native-reanimated"; | 55 | } from "react-native-reanimated"; |
| 54 | import { useThemeColor } from "../../lib/useThemeColor"; | 56 | import { useThemeColor } from "../../lib/useThemeColor"; |
| ... | @@ -80,7 +82,9 @@ import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../. | ... | @@ -80,7 +82,9 @@ import { deriveCenteredContentHorizontalPadding, type LayoutVariant } from "../. |
| 80 | import { | 82 | import { |
| 81 | resolveMarkdownFontSizes, | 83 | resolveMarkdownFontSizes, |
| 82 | resolveNativeMarkdownTypography, | 84 | resolveNativeMarkdownTypography, |
| | 85 | scaledTypographyLineHeight, |
| 83 | } from "../../lib/appearancePreferences"; | 86 | } from "../../lib/appearancePreferences"; |
| | 87 | import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; |
| 84 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | 88 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 85 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | 89 | import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; |
| 86 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | 90 | import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; |
| ... | @@ -91,7 +95,12 @@ import { | ... | @@ -91,7 +95,12 @@ import { |
| 91 | type ThreadFeedLatestTurn, | 95 | type ThreadFeedLatestTurn, |
| 92 | } from "../../lib/threadActivity"; | 96 | } from "../../lib/threadActivity"; |
| 93 | import type { ThreadContentPresentation } from "./threadContentPresentation"; | 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 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; | 104 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 96 | import { useAssetUrl } from "../../state/assets"; | 105 | import { useAssetUrl } from "../../state/assets"; |
| 97 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; | 106 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| ... | @@ -109,8 +118,22 @@ function formatMessageTime(input: string): string { | ... | @@ -109,8 +118,22 @@ function formatMessageTime(input: string): string { |
| 109 | } | 118 | } |
| 110 | | 119 | |
| 111 | // Rows shift when content above them grows (streaming text, work-log folds); | 120 | // Rows shift when content above them grows (streaming text, work-log folds); |
| 112 | // animating the container position turns those jumps into slides. | 121 | // animating the container position turns those jumps into slides. Applied |
| 113 | const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180); | 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 | // Entering animations must only play for rows born just now — LegendList | 138 | // Entering animations must only play for rows born just now — LegendList |
| 116 | // remounts rows when they scroll back into view, and replaying an entrance for | 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,6 +1324,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1301 | const headerMaterialVisibleRef = useRef(false); | 1324 | const headerMaterialVisibleRef = useRef(false); |
| 1302 | const previousLatestTurnRef = useRef(props.latestTurn); | 1325 | const previousLatestTurnRef = useRef(props.latestTurn); |
| 1303 | const { width: windowWidth } = useWindowDimensions(); | 1326 | const { width: windowWidth } = useWindowDimensions(); |
| | 1327 | const { appearance } = useAppearancePreferences(); |
| 1304 | const [viewportWidth, setViewportWidth] = useState(() => | 1328 | const [viewportWidth, setViewportWidth] = useState(() => |
| 1305 | props.layoutVariant === "split" ? 0 : windowWidth, | 1329 | props.layoutVariant === "split" ? 0 : windowWidth, |
| 1306 | ); | 1330 | ); |
| ... | @@ -1410,6 +1434,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1410,6 +1434,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1410 | }, | 1434 | }, |
| 1411 | [props.onHeaderMaterialVisibilityChange], | 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 | const handleScroll = useCallback( | 1442 | const handleScroll = useCallback( |
| 1414 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | 1443 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { |
| 1415 | // anchorTopInset, not topContentInset: under automatic insets the list | 1444 | // anchorTopInset, not topContentInset: under automatic insets the list |
| ... | @@ -1417,9 +1446,40 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1417,9 +1446,40 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1417 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the | 1446 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the |
| 1418 | // header height back or the material toggles a full header too late. | 1447 | // header height back or the material toggles a full header too late. |
| 1419 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); | 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 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { | 1483 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { |
| 1424 | const nextWidth = Math.round(event.nativeEvent.layout.width); | 1484 | const nextWidth = Math.round(event.nativeEvent.layout.width); |
| 1425 | const nextHeight = Math.round(event.nativeEvent.layout.height); | 1485 | const nextHeight = Math.round(event.nativeEvent.layout.height); |
| ... | @@ -1636,6 +1696,38 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1636,6 +1696,38 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1636 | setExpandedImage({ uri, headers }); | 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 | const renderItem = useCallback( | 1731 | const renderItem = useCallback( |
| 1640 | (info: { item: ThreadFeedEntry; index: number }) => | 1732 | (info: { item: ThreadFeedEntry; index: number }) => |
| 1641 | renderFeedEntry(info, { | 1733 | renderFeedEntry(info, { |
| ... | @@ -1725,7 +1817,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1725,7 +1817,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1725 | } | 1817 | } |
| 1726 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} | 1818 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} |
| 1727 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} | 1819 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 1728 | itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION} | 1820 | itemLayoutAnimation={feedItemLayoutTransition} |
| 1729 | // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): | 1821 | // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): |
| 1730 | // lets its scroll math clamp programmatic scrolls to -headerInset | 1822 | // lets its scroll math clamp programmatic scrolls to -headerInset |
| 1731 | // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short | 1823 | // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short |
| ... | @@ -1769,6 +1861,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1769,6 +1861,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1769 | getItemType={(entry) => | 1861 | getItemType={(entry) => |
| 1770 | entry.type === "message" ? `message:${entry.message.role}` : entry.type | 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 | keyboardShouldPersistTaps="always" | 1868 | keyboardShouldPersistTaps="always" |
| 1773 | keyboardDismissMode="none" | 1869 | keyboardDismissMode="none" |
| 1774 | keyboardLiftBehavior="whenAtEnd" | 1870 | keyboardLiftBehavior="whenAtEnd" |
| ... | @@ -1788,6 +1884,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1788,6 +1884,11 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1788 | // user overscroll back to the adjusted rest position. | 1884 | // user overscroll back to the adjusted rest position. |
| 1789 | scrollToOverflowEnabled | 1885 | scrollToOverflowEnabled |
| 1790 | estimatedItemSize={180} | 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 | initialScrollAtEnd | 1892 | initialScrollAtEnd |
| 1792 | onScroll={handleScroll} | 1893 | onScroll={handleScroll} |
| 1793 | scrollEventThrottle={16} | 1894 | scrollEventThrottle={16} |