@@ -13,7 +13,6 @@ import {
1313 splitCodexArtifactTemplateMarkdown ,
1414} from "@t3tools/client-runtime/codex-markdown-directives" ;
1515import { CHAT_LIST_ANCHOR_OFFSET , resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList" ;
16- import { formatElapsed } from "@t3tools/shared/orchestrationTiming" ;
1716import { SymbolView , type AppSymbolName } from "../../components/AppSymbol" ;
1817import { HeaderHeightContext } from "@react-navigation/elements" ;
1918import { useNavigation } from "@react-navigation/native" ;
@@ -54,7 +53,13 @@ import {
5453import { TouchableOpacity } from "react-native-gesture-handler" ;
5554import ImageViewing from "react-native-image-viewing" ;
5655import { useSafeAreaInsets } from "react-native-safe-area-context" ;
57- import Animated , { FadeIn , FadeInUp , type SharedValue } from "react-native-reanimated" ;
56+ import Animated , {
57+ FadeIn ,
58+ FadeInUp ,
59+ FadeOut ,
60+ LinearTransition ,
61+ type SharedValue ,
62+ } from "react-native-reanimated" ;
5863import { useUniwindTheme } from "../../lib/useUniwindTheme" ;
5964import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics" ;
6065import { useFontFamily } from "../../lib/useFontFamily" ;
@@ -93,9 +98,7 @@ import {
9398import {
9499 resolveMarkdownFontSizes ,
95100 resolveNativeMarkdownTypography ,
96- scaledTypographyLineHeight ,
97101} from "../../lib/appearancePreferences" ;
98- import { MOBILE_TYPOGRAPHY } from "../../lib/typography" ;
99102import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider" ;
100103import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface" ;
101104import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons" ;
@@ -114,6 +117,7 @@ import {
114117 collapsedWorkLogHeight ,
115118 ThreadWorkGroupToggle ,
116119 ThreadWorkLog ,
120+ THREAD_DISCLOSURE_TRANSITION_MS ,
117121 WORK_GROUP_TOGGLE_HEIGHT ,
118122} from "./thread-work-log" ;
119123import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState" ;
@@ -143,9 +147,10 @@ function formatMessageTime(input: string): string {
143147// so its height is a constant; a drifted value costs one correction on
144148// measure, not a persistent offset.
145149const TURN_FOLD_HEIGHT = 56 ; // min-h-11 (44) + mb-3 (12)
146- // The working row has no min-height clamp — its height follows the scaled
147- // text-xs line height (see workingRowHeight in ThreadFeed).
148- const WORKING_ROW_VERTICAL_EXTRAS = 24 ; // py-1 (8) + mb-4 (16)
150+ const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition . duration ( THREAD_DISCLOSURE_TRANSITION_MS ) ;
151+ const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn . duration ( 140 ) ;
152+ const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut . duration ( 120 ) ;
153+ const EMPTY_DISCLOSURE_ENTRY_IDS : ReadonlySet < string > = new Set ( ) ;
149154
150155// Entering animations must only play for rows born just now — LegendList
151156// remounts rows when they scroll back into view, and replaying an entrance for
@@ -1104,10 +1109,6 @@ function renderFeedEntry(
11041109 const entry = info . item ;
11051110 const { markdownStyles, iconSubtleColor, userBubbleColor } = props ;
11061111
1107- if ( entry . type === "working" ) {
1108- return < WorkingTimelineRow startedAt = { entry . createdAt } /> ;
1109- }
1110-
11111112 if ( entry . type === "turn-fold" ) {
11121113 return (
11131114 < Pressable
@@ -1136,7 +1137,10 @@ function renderFeedEntry(
11361137 expanded = { entry . expanded }
11371138 hiddenCount = { entry . hiddenCount }
11381139 iconSubtleColor = { iconSubtleColor }
1139- onlyToolActivities = { entry . onlyToolActivities }
1140+ summary = { entry . summary }
1141+ summaryKind = { entry . summaryKind }
1142+ hasFailure = { entry . hasFailure }
1143+ shimmer = { entry . shimmer }
11401144 onToggle = { ( ) => props . onToggleWorkGroup ( entry . groupId ) }
11411145 />
11421146 ) ;
@@ -1299,32 +1303,6 @@ function renderFeedEntry(
12991303 ) ;
13001304}
13011305
1302- const WorkingTimelineRow = memo ( function WorkingTimelineRow ( props : { readonly startedAt : string } ) {
1303- const [ nowMs , setNowMs ] = useState ( ( ) => Date . now ( ) ) ;
1304-
1305- useEffect ( ( ) => {
1306- const intervalId = setInterval ( ( ) => {
1307- setNowMs ( Date . now ( ) ) ;
1308- } , 1_000 ) ;
1309- return ( ) => clearInterval ( intervalId ) ;
1310- } , [ props . startedAt ] ) ;
1311-
1312- const durationLabel = formatElapsed ( props . startedAt , new Date ( nowMs ) . toISOString ( ) ) ?? "0s" ;
1313-
1314- return (
1315- < View className = "mb-4 flex-row items-center gap-2 px-1.5 py-1" >
1316- < View className = "flex-row items-center gap-1" >
1317- < View className = "h-1 w-1 rounded-full bg-adaptive-neutral-400-500" />
1318- < View className = "h-1 w-1 rounded-full bg-adaptive-neutral-400-a80-500-a80" />
1319- < View className = "h-1 w-1 rounded-full bg-adaptive-neutral-400-a60-500-a60" />
1320- </ View >
1321- < Text className = "font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400" >
1322- Working for { durationLabel }
1323- </ Text >
1324- </ View >
1325- ) ;
1326- } ) ;
1327-
13281306function UserMessageContent ( props : {
13291307 readonly text : string ;
13301308 readonly markdownStyles : MarkdownStyleSet ;
@@ -1598,14 +1576,14 @@ function ThreadFeedPlaceholder(props: {
15981576export const ThreadFeed = memo ( function ThreadFeed ( props : ThreadFeedProps ) {
15991577 const navigation = useNavigation ( ) ;
16001578 const copyFeedbackTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
1601- const foldSettleFrameRef = useRef < number | null > ( null ) ;
1602- const foldSettleSecondFrameRef = useRef < number | null > ( null ) ;
1579+ const disclosureSettleFrameRef = useRef < number | null > ( null ) ;
1580+ const disclosureSettleSecondFrameRef = useRef < number | null > ( null ) ;
16031581 const disclosureAnchorKeyRef = useRef < string | null > ( null ) ;
1582+ const previousPresentedFeedRef = useRef < ReadonlyArray < ThreadFeedEntry > | null > ( null ) ;
16041583 const headerMaterialVisibleRef = useRef ( false ) ;
16051584 const previousLatestTurnRef = useRef ( props . latestTurn ) ;
16061585 const userScrollSettleTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
16071586 const { width : windowWidth } = useWindowDimensions ( ) ;
1608- const { appearance } = useAppearancePreferences ( ) ;
16091587 const [ viewportWidth , setViewportWidth ] = useState ( ( ) =>
16101588 props . layoutVariant === "split" ? 0 : windowWidth ,
16111589 ) ;
@@ -1910,14 +1888,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19101888 props . latestTurn ,
19111889 ] ,
19121890 ) ;
1891+ const disclosureEnteringEntryIds = useMemo ( ( ) => {
1892+ const anchorKey = disclosureAnchorKeyRef . current ;
1893+ const previousPresentedFeed = previousPresentedFeedRef . current ;
1894+ if ( ! disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null ) {
1895+ return EMPTY_DISCLOSURE_ENTRY_IDS ;
1896+ }
1897+
1898+ const previousIds = new Set ( previousPresentedFeed . map ( ( entry ) => entry . id ) ) ;
1899+ const anchorIndex = presentedFeed . findIndex ( ( entry ) => entry . id === anchorKey ) ;
1900+ const enteringIds = new Set < string > ( ) ;
1901+ if ( anchorIndex < 0 ) {
1902+ return enteringIds ;
1903+ }
1904+ for ( let index = anchorIndex + 1 ; index < presentedFeed . length ; index += 1 ) {
1905+ const entryId = presentedFeed [ index ] ! . id ;
1906+ if ( previousIds . has ( entryId ) ) {
1907+ break ;
1908+ }
1909+ enteringIds . add ( entryId ) ;
1910+ }
1911+ return enteringIds ;
1912+ } , [ disclosureToggleSettling , presentedFeed ] ) ;
1913+
1914+ useLayoutEffect ( ( ) => {
1915+ previousPresentedFeedRef . current = presentedFeed ;
1916+ } , [ presentedFeed ] ) ;
19131917
1914- // The empty↔filled key below remounts the list, which resets its imperative
1915- // content-inset override — and useKeyboardChatComposerInset (mounted above
1916- // the remount boundary) deduplicates by height, so it never re-reports the
1917- // composer inset to the fresh instance. Re-report the measured overlay height
1918- // (composer plus any pending approval / user-input card) so the remounted
1919- // list's scroll math gets the true value; on Android the declarative
1920- // contentInset floor below covers the window before this effect lands.
1918+ // The empty↔filled key below remounts the list and resets its imperative
1919+ // content-inset override. Seed the fresh instance synchronously with the
1920+ // current overlay height before the scroll integration's next reaction;
1921+ // on Android the declarative contentInset floor covers this same window.
19211922 const listMountKey = `${ feedThreadKey } :${ props . feed . length === 0 ? "empty" : "filled" } ` ;
19221923 useLayoutEffect ( ( ) => {
19231924 const bottom = props . contentInsetEndAdjustment . value ;
@@ -1982,34 +1983,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19821983 if ( copyFeedbackTimeoutRef . current ) {
19831984 clearTimeout ( copyFeedbackTimeoutRef . current ) ;
19841985 }
1985- if ( foldSettleFrameRef . current !== null ) {
1986- cancelAnimationFrame ( foldSettleFrameRef . current ) ;
1986+ if ( disclosureSettleFrameRef . current !== null ) {
1987+ cancelAnimationFrame ( disclosureSettleFrameRef . current ) ;
19871988 }
1988- if ( foldSettleSecondFrameRef . current !== null ) {
1989- cancelAnimationFrame ( foldSettleSecondFrameRef . current ) ;
1989+ if ( disclosureSettleSecondFrameRef . current !== null ) {
1990+ cancelAnimationFrame ( disclosureSettleSecondFrameRef . current ) ;
19901991 }
19911992 } ;
19921993 } , [ ] ) ;
19931994
1994- const suspendEndScrollMaintenanceForDisclosure = useCallback ( ( anchorKey : string | null ) => {
1995- disclosureAnchorKeyRef . current = anchorKey ;
1996- setDisclosureToggleSettling ( true ) ;
1997- if ( foldSettleFrameRef . current !== null ) {
1998- cancelAnimationFrame ( foldSettleFrameRef . current ) ;
1995+ const settleDisclosureAfterLayout = useCallback ( ( ) => {
1996+ if ( disclosureSettleFrameRef . current !== null ) {
1997+ cancelAnimationFrame ( disclosureSettleFrameRef . current ) ;
19991998 }
2000- if ( foldSettleSecondFrameRef . current !== null ) {
2001- cancelAnimationFrame ( foldSettleSecondFrameRef . current ) ;
1999+ if ( disclosureSettleSecondFrameRef . current !== null ) {
2000+ cancelAnimationFrame ( disclosureSettleSecondFrameRef . current ) ;
20022001 }
2003- foldSettleFrameRef . current = requestAnimationFrame ( ( ) => {
2004- foldSettleSecondFrameRef . current = requestAnimationFrame ( ( ) => {
2002+ disclosureSettleFrameRef . current = requestAnimationFrame ( ( ) => {
2003+ disclosureSettleSecondFrameRef . current = requestAnimationFrame ( ( ) => {
20052004 disclosureAnchorKeyRef . current = null ;
20062005 setDisclosureToggleSettling ( false ) ;
2007- foldSettleFrameRef . current = null ;
2008- foldSettleSecondFrameRef . current = null ;
2006+ disclosureSettleFrameRef . current = null ;
2007+ disclosureSettleSecondFrameRef . current = null ;
20092008 } ) ;
20102009 } ) ;
20112010 } , [ ] ) ;
20122011
2012+ const suspendEndScrollMaintenanceForDisclosure = useCallback ( ( anchorKey : string | null ) => {
2013+ disclosureAnchorKeyRef . current = anchorKey ;
2014+ setDisclosureToggleSettling ( true ) ;
2015+ } , [ ] ) ;
2016+
2017+ // Start the quiet-frame countdown after React has committed the disclosure.
2018+ // Every measured item-size change restarts it, so end maintenance cannot
2019+ // wake between the data mutation and LegendList's final layout correction.
2020+ useLayoutEffect ( ( ) => {
2021+ if ( disclosureAnchorKeyRef . current !== null ) {
2022+ settleDisclosureAfterLayout ( ) ;
2023+ }
2024+ } , [ expandedTurnIds , expandedWorkGroups , expandedWorkRows , settleDisclosureAfterLayout ] ) ;
2025+
2026+ const handleItemSizeChanged = useCallback ( ( ) => {
2027+ if ( disclosureAnchorKeyRef . current !== null ) {
2028+ settleDisclosureAfterLayout ( ) ;
2029+ }
2030+ } , [ settleDisclosureAfterLayout ] ) ;
2031+
20132032 const shouldRestoreVisibleContentPosition = useCallback ( ( entry : ThreadFeedEntry ) => {
20142033 const disclosureAnchorKey = disclosureAnchorKeyRef . current ;
20152034 return disclosureAnchorKey === null || entry . id === disclosureAnchorKey ;
@@ -2094,59 +2113,65 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20942113 // scrolling up through unmeasured content corrects each row's height as it
20952114 // mounts — the feed visibly jumps. Fixed sizes make the small chrome rows
20962115 // exact; message rows stay undefined and use LegendList's per-type running
2097- // average once one of their type has been measured. Text-driven heights
2098- // follow the configurable base font size via scaledTypographyLineHeight.
2099- const workingRowHeight =
2100- WORKING_ROW_VERTICAL_EXTRAS +
2101- scaledTypographyLineHeight ( MOBILE_TYPOGRAPHY . label , appearance . baseFontSize ) ;
2116+ // average once one of their type has been measured.
21022117 const getFixedItemSize = useCallback (
21032118 ( entry : ThreadFeedEntry ) => {
21042119 switch ( entry . type ) {
21052120 case "turn-fold" :
21062121 return TURN_FOLD_HEIGHT ;
21072122 case "work-toggle" :
21082123 return WORK_GROUP_TOGGLE_HEIGHT ;
2109- case "working" :
2110- return workingRowHeight ;
21112124 case "activity-group" :
21122125 // Expanded rows append a variable detail block — fall back to
21132126 // measurement for those groups.
21142127 return entry . activities . some ( ( activity ) => expandedWorkRows [ activity . id ] )
21152128 ? undefined
2116- : collapsedWorkLogHeight ( entry . activities , appearance . baseFontSize ) ;
2129+ : collapsedWorkLogHeight ( entry . activities ) ;
21172130 default :
21182131 return undefined ;
21192132 }
21202133 } ,
2121- [ expandedWorkRows , workingRowHeight , appearance . baseFontSize ] ,
2134+ [ expandedWorkRows ] ,
21222135 ) ;
21232136
21242137 const renderItem = useCallback (
2125- ( info : { item : ThreadFeedEntry ; index : number } ) =>
2126- renderFeedEntry ( info , {
2127- environmentId : props . environmentId ,
2128- copiedRowId,
2129- expandedWorkRows,
2130- terminalAssistantMessageIds,
2131- unsettledTurnId,
2132- onCopyWorkRow,
2133- onToggleWorkGroup,
2134- onToggleWorkRow,
2135- onToggleTurnFold,
2136- onPressImage,
2137- onMarkdownLinkPress,
2138- renderMarkdownImage,
2139- iconSubtleColor,
2140- userBubbleColor,
2141- markdownStyles,
2142- reviewCommentColors,
2143- reviewCommentBubbleWidth,
2144- userBubbleMaxWidth,
2145- skills : props . skills ,
2146- onUseArtifactTemplate : props . onUseArtifactTemplate ,
2147- } ) ,
2138+ ( info : { item : ThreadFeedEntry ; index : number } ) => (
2139+ < Animated . View
2140+ key = { info . item . id }
2141+ entering = {
2142+ disclosureEnteringEntryIds . has ( info . item . id )
2143+ ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2144+ : undefined
2145+ }
2146+ exiting = { THREAD_FEED_DISCLOSURE_EXIT_TRANSITION }
2147+ >
2148+ { renderFeedEntry ( info , {
2149+ environmentId : props . environmentId ,
2150+ copiedRowId,
2151+ expandedWorkRows,
2152+ terminalAssistantMessageIds,
2153+ unsettledTurnId,
2154+ onCopyWorkRow,
2155+ onToggleWorkGroup,
2156+ onToggleWorkRow,
2157+ onToggleTurnFold,
2158+ onPressImage,
2159+ onMarkdownLinkPress,
2160+ renderMarkdownImage,
2161+ iconSubtleColor,
2162+ userBubbleColor,
2163+ markdownStyles,
2164+ reviewCommentColors,
2165+ reviewCommentBubbleWidth,
2166+ userBubbleMaxWidth,
2167+ skills : props . skills ,
2168+ onUseArtifactTemplate : props . onUseArtifactTemplate ,
2169+ } ) }
2170+ </ Animated . View >
2171+ ) ,
21482172 [
21492173 copiedRowId ,
2174+ disclosureEnteringEntryIds ,
21502175 expandedWorkRows ,
21512176 terminalAssistantMessageIds ,
21522177 unsettledTurnId ,
@@ -2214,7 +2239,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22142239 }
22152240 : { scrollIndicatorInsets : { top : topContentInset , bottom : 0 } } ) }
22162241 { ...( anchoredEndSpace ? { anchoredEndSpace } : { } ) }
2217- // Patched LegendList prop (patches/@legendapp__list@3.2.0 .patch):
2242+ // Patched LegendList prop (patches/@legendapp__list@3.3.5 .patch):
22182243 // lets its scroll math clamp programmatic scrolls to -headerInset
22192244 // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short
22202245 // content rest below the transparent header rather than at frame top.
@@ -2269,6 +2294,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22692294 entry . type === "message" ? `message:${ entry . message . role } ` : entry . type
22702295 }
22712296 getFixedItemSize = { getFixedItemSize }
2297+ itemLayoutAnimation = { THREAD_FEED_LAYOUT_TRANSITION }
2298+ onItemSizeChanged = { handleItemSizeChanged }
22722299 // Measure rows well before they scroll into view so estimate→actual
22732300 // corrections land offscreen instead of under the user's finger.
22742301 drawDistance = { 500 }
0 commit comments