@@ -11,7 +11,6 @@ import {
1111 type RunId ,
1212} from "@t3tools/contracts" ;
1313import { CHAT_LIST_ANCHOR_OFFSET , resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList" ;
14- import { formatElapsed } from "@t3tools/shared/orchestrationTiming" ;
1514import { SymbolView } from "../../components/AppSymbol" ;
1615import { HeaderHeightContext } from "@react-navigation/elements" ;
1716import { useNavigation } from "@react-navigation/native" ;
@@ -53,7 +52,13 @@ import {
5352import { TouchableOpacity } from "react-native-gesture-handler" ;
5453import ImageViewing from "react-native-image-viewing" ;
5554import { useSafeAreaInsets } from "react-native-safe-area-context" ;
56- import Animated , { FadeIn , FadeInUp , type SharedValue } from "react-native-reanimated" ;
55+ import Animated , {
56+ FadeIn ,
57+ FadeInUp ,
58+ FadeOut ,
59+ LinearTransition ,
60+ type SharedValue ,
61+ } from "react-native-reanimated" ;
5762import { useUniwindTheme } from "../../lib/useUniwindTheme" ;
5863import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics" ;
5964import { useFontFamily } from "../../lib/useFontFamily" ;
@@ -104,7 +109,11 @@ import {
104109 resolveThreadFeedLiveFollow ,
105110 type ThreadFeedLiveFollowEvent ,
106111} from "./thread-feed-live-follow" ;
107- import { ThreadWorkGroupToggle , ThreadWorkLog } from "./thread-work-log" ;
112+ import {
113+ THREAD_DISCLOSURE_TRANSITION_MS ,
114+ ThreadWorkGroupToggle ,
115+ ThreadWorkLog ,
116+ } from "./thread-work-log" ;
108117import { resolveThreadFeedFixedItemSize } from "./thread-feed-item-size" ;
109118import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState" ;
110119import { useAssetUrl , useAssetUrlState } from "../../state/assets" ;
@@ -133,6 +142,11 @@ function formatMessageTime(input: string): string {
133142 return MESSAGE_TIME_FORMATTER . format ( timestamp ) ;
134143}
135144
145+ const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition . duration ( THREAD_DISCLOSURE_TRANSITION_MS ) ;
146+ const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn . duration ( 140 ) ;
147+ const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut . duration ( 120 ) ;
148+ const EMPTY_DISCLOSURE_ENTRY_IDS : ReadonlySet < string > = new Set ( ) ;
149+
136150// Entering animations must only play for rows born just now — LegendList
137151// remounts rows when they scroll back into view, and replaying an entrance for
138152// old content would be its own kind of jank.
@@ -1035,12 +1049,11 @@ function renderFeedEntry(
10351049 info : { item : ThreadFeedEntry ; index : number } ,
10361050 props : Pick < ThreadFeedProps , "environmentId" | "skills" | "threadId" | "workspaceRoot" > & {
10371051 readonly copiedRowId : string | null ;
1038- readonly expandedWorkGroups : Record < string , boolean > ;
10391052 readonly expandedWorkRows : Record < string , boolean > ;
10401053 readonly terminalAssistantMessageIds : ReadonlySet < string > ;
10411054 readonly unsettledTurnId : RunId | null ;
10421055 readonly onCopyWorkRow : ( rowId : string , value : string ) => void ;
1043- readonly onToggleWorkGroup : ( groupId : string ) => void ;
1056+ readonly onToggleWorkGroup : ( groupId : string , anchorKey ?: string ) => void ;
10441057 readonly onToggleWorkRow : ( rowId : string , anchorKey ?: string ) => void ;
10451058 readonly onToggleTurnFold : ( runId : RunId ) => void ;
10461059 readonly onPressImage : ( uri : string , headers ?: Record < string , string > ) => void ;
@@ -1057,10 +1070,6 @@ function renderFeedEntry(
10571070 const entry = info . item ;
10581071 const { markdownStyles, iconSubtleColor, userBubbleColor } = props ;
10591072
1060- if ( entry . type === "working" ) {
1061- return < WorkingTimelineRow startedAt = { entry . createdAt } /> ;
1062- }
1063-
10641073 if ( entry . type === "run-fold" ) {
10651074 return (
10661075 < Pressable
@@ -1089,8 +1098,11 @@ function renderFeedEntry(
10891098 expanded = { entry . expanded }
10901099 hiddenCount = { entry . hiddenCount }
10911100 iconSubtleColor = { iconSubtleColor }
1092- onlyToolActivities = { entry . onlyToolActivities }
1093- onToggle = { ( ) => props . onToggleWorkGroup ( entry . groupId ) }
1101+ summary = { entry . summary }
1102+ summaryKind = { entry . summaryKind }
1103+ hasFailure = { entry . hasFailure }
1104+ shimmer = { entry . shimmer }
1105+ onToggle = { ( ) => props . onToggleWorkGroup ( entry . groupId , entry . id ) }
10941106 />
10951107 ) ;
10961108 }
@@ -1282,43 +1294,15 @@ function renderFeedEntry(
12821294 copiedRowId = { props . copiedRowId }
12831295 currentThreadId = { props . threadId }
12841296 environmentId = { props . environmentId }
1285- expanded = { props . expandedWorkGroups [ entry . id ] ?? false }
12861297 expandedRows = { props . expandedWorkRows }
12871298 iconSubtleColor = { iconSubtleColor }
12881299 onCopyRow = { props . onCopyWorkRow }
1289- onToggleGroup = { ( ) => props . onToggleWorkGroup ( entry . id ) }
12901300 onToggleRow = { ( rowId ) => props . onToggleWorkRow ( rowId , entry . id ) }
12911301 workspaceRoot = { props . workspaceRoot }
12921302 />
12931303 ) ;
12941304}
12951305
1296- const WorkingTimelineRow = memo ( function WorkingTimelineRow ( props : { readonly startedAt : string } ) {
1297- const [ nowMs , setNowMs ] = useState ( ( ) => Date . now ( ) ) ;
1298-
1299- useEffect ( ( ) => {
1300- const intervalId = setInterval ( ( ) => {
1301- setNowMs ( Date . now ( ) ) ;
1302- } , 1_000 ) ;
1303- return ( ) => clearInterval ( intervalId ) ;
1304- } , [ props . startedAt ] ) ;
1305-
1306- const durationLabel = formatElapsed ( props . startedAt , new Date ( nowMs ) . toISOString ( ) ) ?? "0s" ;
1307-
1308- return (
1309- < View className = "mb-4 flex-row items-center gap-2 px-1.5 py-1" >
1310- < View className = "flex-row items-center gap-1" >
1311- < View className = "h-1 w-1 rounded-full bg-adaptive-neutral-400-500" />
1312- < View className = "h-1 w-1 rounded-full bg-adaptive-neutral-400-a80-500-a80" />
1313- < View className = "h-1 w-1 rounded-full bg-adaptive-neutral-400-a60-500-a60" />
1314- </ View >
1315- < Text className = "font-t3-medium text-xs tabular-nums text-adaptive-neutral-600-400" >
1316- Working for { durationLabel }
1317- </ Text >
1318- </ View >
1319- ) ;
1320- } ) ;
1321-
13221306function UserMessageContent ( props : {
13231307 readonly text : string ;
13241308 readonly markdownStyles : MarkdownStyleSet ;
@@ -1625,14 +1609,14 @@ function ThreadFeedPlaceholder(props: {
16251609export const ThreadFeed = memo ( function ThreadFeed ( props : ThreadFeedProps ) {
16261610 const navigation = useNavigation ( ) ;
16271611 const copyFeedbackTimeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
1628- const foldSettleFrameRef = useRef < number | null > ( null ) ;
1629- const foldSettleSecondFrameRef = useRef < number | null > ( null ) ;
1612+ const disclosureSettleFrameRef = useRef < number | null > ( null ) ;
1613+ const disclosureSettleSecondFrameRef = useRef < number | null > ( null ) ;
1614+ const disclosureAnchorKeyRef = useRef < string | null > ( null ) ;
1615+ const previousPresentedFeedRef = useRef < ReadonlyArray < ThreadFeedEntry > | null > ( null ) ;
16301616 const previousLatestTurnRef = useRef ( props . latestRun ) ;
16311617 const userScrollSettleTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
1632- const disclosureAnchorKeyRef = useRef < string | null > ( null ) ;
16331618 const headerMaterialVisibleRef = useRef ( false ) ;
16341619 const { width : windowWidth } = useWindowDimensions ( ) ;
1635- const { appearance } = useAppearancePreferences ( ) ;
16361620 const [ viewportWidth , setViewportWidth ] = useState ( ( ) =>
16371621 props . layoutVariant === "split" ? 0 : windowWidth ,
16381622 ) ;
@@ -1893,6 +1877,32 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18931877 ) ,
18941878 [ expandedTurnIds , expandedWorkGroups , props . activeWorkStartedAt , props . feed , props . latestRun ] ,
18951879 ) ;
1880+ const disclosureEnteringEntryIds = useMemo ( ( ) => {
1881+ const anchorKey = disclosureAnchorKeyRef . current ;
1882+ const previousPresentedFeed = previousPresentedFeedRef . current ;
1883+ if ( ! disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null ) {
1884+ return EMPTY_DISCLOSURE_ENTRY_IDS ;
1885+ }
1886+
1887+ const previousIds = new Set ( previousPresentedFeed . map ( ( entry ) => entry . id ) ) ;
1888+ const anchorIndex = presentedFeed . findIndex ( ( entry ) => entry . id === anchorKey ) ;
1889+ const enteringIds = new Set < string > ( ) ;
1890+ if ( anchorIndex < 0 ) {
1891+ return enteringIds ;
1892+ }
1893+ for ( let index = anchorIndex + 1 ; index < presentedFeed . length ; index += 1 ) {
1894+ const entryId = presentedFeed [ index ] ! . id ;
1895+ if ( previousIds . has ( entryId ) ) {
1896+ break ;
1897+ }
1898+ enteringIds . add ( entryId ) ;
1899+ }
1900+ return enteringIds ;
1901+ } , [ disclosureToggleSettling , presentedFeed ] ) ;
1902+
1903+ useLayoutEffect ( ( ) => {
1904+ previousPresentedFeedRef . current = presentedFeed ;
1905+ } , [ presentedFeed ] ) ;
18961906
18971907 // The empty↔filled key below remounts the list, which resets its imperative
18981908 // content-inset override — and useKeyboardChatComposerInset (mounted above
@@ -1961,34 +1971,52 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
19611971 if ( copyFeedbackTimeoutRef . current ) {
19621972 clearTimeout ( copyFeedbackTimeoutRef . current ) ;
19631973 }
1964- if ( foldSettleFrameRef . current !== null ) {
1965- cancelAnimationFrame ( foldSettleFrameRef . current ) ;
1974+ if ( disclosureSettleFrameRef . current !== null ) {
1975+ cancelAnimationFrame ( disclosureSettleFrameRef . current ) ;
19661976 }
1967- if ( foldSettleSecondFrameRef . current !== null ) {
1968- cancelAnimationFrame ( foldSettleSecondFrameRef . current ) ;
1977+ if ( disclosureSettleSecondFrameRef . current !== null ) {
1978+ cancelAnimationFrame ( disclosureSettleSecondFrameRef . current ) ;
19691979 }
19701980 } ;
19711981 } , [ ] ) ;
19721982
1973- const suspendEndScrollMaintenanceForDisclosure = useCallback ( ( anchorKey : string | null ) => {
1974- disclosureAnchorKeyRef . current = anchorKey ;
1975- setDisclosureToggleSettling ( true ) ;
1976- if ( foldSettleFrameRef . current !== null ) {
1977- cancelAnimationFrame ( foldSettleFrameRef . current ) ;
1983+ const settleDisclosureAfterLayout = useCallback ( ( ) => {
1984+ if ( disclosureSettleFrameRef . current !== null ) {
1985+ cancelAnimationFrame ( disclosureSettleFrameRef . current ) ;
19781986 }
1979- if ( foldSettleSecondFrameRef . current !== null ) {
1980- cancelAnimationFrame ( foldSettleSecondFrameRef . current ) ;
1987+ if ( disclosureSettleSecondFrameRef . current !== null ) {
1988+ cancelAnimationFrame ( disclosureSettleSecondFrameRef . current ) ;
19811989 }
1982- foldSettleFrameRef . current = requestAnimationFrame ( ( ) => {
1983- foldSettleSecondFrameRef . current = requestAnimationFrame ( ( ) => {
1990+ disclosureSettleFrameRef . current = requestAnimationFrame ( ( ) => {
1991+ disclosureSettleSecondFrameRef . current = requestAnimationFrame ( ( ) => {
19841992 disclosureAnchorKeyRef . current = null ;
19851993 setDisclosureToggleSettling ( false ) ;
1986- foldSettleFrameRef . current = null ;
1987- foldSettleSecondFrameRef . current = null ;
1994+ disclosureSettleFrameRef . current = null ;
1995+ disclosureSettleSecondFrameRef . current = null ;
19881996 } ) ;
19891997 } ) ;
19901998 } , [ ] ) ;
19911999
2000+ const suspendEndScrollMaintenanceForDisclosure = useCallback ( ( anchorKey : string | null ) => {
2001+ disclosureAnchorKeyRef . current = anchorKey ;
2002+ setDisclosureToggleSettling ( true ) ;
2003+ } , [ ] ) ;
2004+
2005+ // Start the quiet-frame countdown after React has committed the disclosure.
2006+ // Every measured item-size change restarts it, so end maintenance cannot
2007+ // wake between the data mutation and LegendList's final layout correction.
2008+ useLayoutEffect ( ( ) => {
2009+ if ( disclosureAnchorKeyRef . current !== null ) {
2010+ settleDisclosureAfterLayout ( ) ;
2011+ }
2012+ } , [ expandedTurnIds , expandedWorkGroups , expandedWorkRows , settleDisclosureAfterLayout ] ) ;
2013+
2014+ const handleItemSizeChanged = useCallback ( ( ) => {
2015+ if ( disclosureAnchorKeyRef . current !== null ) {
2016+ settleDisclosureAfterLayout ( ) ;
2017+ }
2018+ } , [ settleDisclosureAfterLayout ] ) ;
2019+
19922020 const shouldRestoreVisibleContentPosition = useCallback ( ( entry : ThreadFeedEntry ) => {
19932021 const disclosureAnchorKey = disclosureAnchorKeyRef . current ;
19942022 return disclosureAnchorKey === null || entry . id === disclosureAnchorKey ;
@@ -2021,8 +2049,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20212049 } , [ ] ) ;
20222050
20232051 const onToggleWorkGroup = useCallback (
2024- ( groupId : string ) => {
2025- suspendEndScrollMaintenanceForDisclosure ( `work-toggle:${ groupId } ` ) ;
2052+ ( groupId : string , anchorKey ?: string ) => {
2053+ suspendEndScrollMaintenanceForDisclosure ( anchorKey ?? `work-toggle:${ groupId } ` ) ;
20262054 setInteractionState ( ( current ) => ( {
20272055 ...current ,
20282056 expandedWorkGroups : {
@@ -2078,39 +2106,49 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
20782106 // link, so marking them fixed would make LegendList skip their measurement
20792107 // and position the next message inside the card.
20802108 const getFixedItemSize = useCallback (
2081- ( entry : ThreadFeedEntry ) => resolveThreadFeedFixedItemSize ( entry . type , appearance . baseFontSize ) ,
2082- [ appearance . baseFontSize ] ,
2109+ ( entry : ThreadFeedEntry ) => resolveThreadFeedFixedItemSize ( entry . type ) ,
2110+ [ ] ,
20832111 ) ;
20842112
20852113 const renderItem = useCallback (
2086- ( info : { item : ThreadFeedEntry ; index : number } ) =>
2087- renderFeedEntry ( info , {
2088- environmentId : props . environmentId ,
2089- threadId : props . threadId ,
2090- copiedRowId,
2091- expandedWorkGroups,
2092- expandedWorkRows,
2093- terminalAssistantMessageIds,
2094- unsettledTurnId,
2095- onCopyWorkRow,
2096- onToggleWorkGroup,
2097- onToggleWorkRow,
2098- onToggleTurnFold,
2099- onPressImage,
2100- onMarkdownLinkPress,
2101- iconSubtleColor,
2102- userBubbleColor,
2103- markdownStyles,
2104- reviewCommentColors,
2105- reviewCommentBubbleWidth,
2106- userBubbleMaxWidth,
2107- threadTitle : props . threadTitle ,
2108- skills : props . skills ,
2109- workspaceRoot : props . workspaceRoot ,
2110- } ) ,
2114+ ( info : { item : ThreadFeedEntry ; index : number } ) => (
2115+ < Animated . View
2116+ key = { info . item . id }
2117+ entering = {
2118+ disclosureEnteringEntryIds . has ( info . item . id )
2119+ ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2120+ : undefined
2121+ }
2122+ exiting = { THREAD_FEED_DISCLOSURE_EXIT_TRANSITION }
2123+ >
2124+ { renderFeedEntry ( info , {
2125+ environmentId : props . environmentId ,
2126+ threadId : props . threadId ,
2127+ copiedRowId,
2128+ expandedWorkRows,
2129+ terminalAssistantMessageIds,
2130+ unsettledTurnId,
2131+ onCopyWorkRow,
2132+ onToggleWorkGroup,
2133+ onToggleWorkRow,
2134+ onToggleTurnFold,
2135+ onPressImage,
2136+ onMarkdownLinkPress,
2137+ iconSubtleColor,
2138+ userBubbleColor,
2139+ markdownStyles,
2140+ reviewCommentColors,
2141+ reviewCommentBubbleWidth,
2142+ userBubbleMaxWidth,
2143+ threadTitle : props . threadTitle ,
2144+ skills : props . skills ,
2145+ workspaceRoot : props . workspaceRoot ,
2146+ } ) }
2147+ </ Animated . View >
2148+ ) ,
21112149 [
21122150 copiedRowId ,
2113- expandedWorkGroups ,
2151+ disclosureEnteringEntryIds ,
21142152 expandedWorkRows ,
21152153 terminalAssistantMessageIds ,
21162154 unsettledTurnId ,
@@ -2223,6 +2261,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22232261 entry . type === "message" ? `message:${ entry . message . role } ` : entry . type
22242262 }
22252263 getFixedItemSize = { getFixedItemSize }
2264+ itemLayoutAnimation = { THREAD_FEED_LAYOUT_TRANSITION }
2265+ onItemSizeChanged = { handleItemSizeChanged }
22262266 // Measure rows well before they scroll into view so estimate→actual
22272267 // corrections land offscreen instead of under the user's finger.
22282268 drawDistance = { 500 }
0 commit comments