@@ -71,7 +71,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
7171import Animated , {
7272 FadeIn ,
7373 FadeInUp ,
74- FadeOut ,
7574 LinearTransition ,
7675 type SharedValue ,
7776} from "react-native-reanimated" ;
@@ -133,6 +132,7 @@ import {
133132} from "./thread-feed-live-follow" ;
134133import {
135134 collapsedWorkLogHeight ,
135+ ThreadDisclosureChevron ,
136136 ThreadWorkGroupToggle ,
137137 ThreadWorkLog ,
138138 THREAD_DISCLOSURE_TRANSITION_MS ,
@@ -167,11 +167,12 @@ function formatMessageTime(input: string): string {
167167// text-sm line at every supported base font size (26px at the 22pt maximum),
168168// so its height is a constant; a drifted value costs one correction on
169169// measure, not a persistent offset.
170- const TURN_FOLD_HEIGHT = 56 ; // min-h-11 (44) + mb-3 (12 )
170+ const TURN_FOLD_HEIGHT = 48 ; // min-h-11 (44) + mb-1 (4 )
171171const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition . duration ( THREAD_DISCLOSURE_TRANSITION_MS ) ;
172- const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn . duration ( 140 ) ;
173- const THREAD_FEED_DISCLOSURE_EXIT_TRANSITION = FadeOut . duration ( 120 ) ;
174- const EMPTY_DISCLOSURE_ENTRY_IDS : ReadonlySet < string > = new Set ( ) ;
172+ // Let neighboring rows move out of the new rows' space before showing their text.
173+ const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn . delay (
174+ THREAD_DISCLOSURE_TRANSITION_MS ,
175+ ) . duration ( 140 ) ;
175176
176177// Entering animations must only play for rows born just now — LegendList
177178// remounts rows when they scroll back into view, and replaying an entrance for
@@ -1357,16 +1358,16 @@ function renderFeedEntry(
13571358 accessibilityState = { { expanded : entry . expanded } }
13581359 onPress = { ( ) => props . onToggleTurnFold ( entry . turnId ) }
13591360 hitSlop = { 4 }
1360- className = "mb-3 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
1361+ className = "mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2"
13611362 >
13621363 < Text className = "font-t3-medium text-sm tabular-nums text-foreground-muted" >
13631364 { entry . label }
13641365 </ Text >
1365- < SymbolView
1366- name = { entry . expanded ? "chevron.down" : "chevron.right" }
1366+ < ThreadDisclosureChevron
1367+ expanded = { entry . expanded }
1368+ collapsedDirection = "right"
13671369 size = { 15 }
1368- tintColorClassName = { "accent-icon-subtle" }
1369- type = "monochrome"
1370+ tintColor = { iconSubtleColor }
13701371 />
13711372 </ Pressable >
13721373 ) ;
@@ -1490,7 +1491,7 @@ function renderFeedEntry(
14901491 const enterAnimated = isFreshTimestamp ( message . createdAt ) ;
14911492 return (
14921493 < Animated . View
1493- className = { cn ( showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1" ) }
1494+ className = { cn ( showAssistantMeta ? "mb-5 px-1" : "mb-1 px-1" ) }
14941495 { ...( enterAnimated ? { entering : FadeIn . duration ( 220 ) } : { } ) }
14951496 >
14961497 { renderedText . trim ( ) . length > 0 ? (
@@ -1840,7 +1841,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18401841 const disclosureSettleFrameRef = useRef < number | null > ( null ) ;
18411842 const disclosureSettleSecondFrameRef = useRef < number | null > ( null ) ;
18421843 const disclosureAnchorKeyRef = useRef < string | null > ( null ) ;
1843- const previousPresentedFeedRef = useRef < ReadonlyArray < ThreadFeedEntry > | null > ( null ) ;
18441844 const headerMaterialVisibleRef = useRef ( false ) ;
18451845 const previousLatestTurnRef = useRef ( props . latestTurn ) ;
18461846 const userScrollSettleTimerRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
@@ -1853,12 +1853,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
18531853 // Live-follow latch. LegendList's maintainScrollAtEnd alone re-pins the feed
18541854 // whenever the viewport drifts back inside its geometric threshold, which
18551855 // yanked users off history they were reading every time a stream chunk grew
1856- // a row. Follow breaks when the user scrolls up and away, and re-arms only
1857- // when the list actually returns to the end (or on send / thread switch) .
1856+ // a row. Scrolling away or expanding a disclosure above the end breaks
1857+ // follow; reaching the end (or sending / switching threads) re-arms it .
18581858 const [ endFollowEnabled , setEndFollowEnabled ] = useState ( true ) ;
18591859 const endFollowEnabledRef = useRef ( true ) ;
18601860 // A "user scroll session" spans from drag start through the end of its
1861- // momentum; only motion inside a session can break follow , so MVCP
1861+ // momentum; scroll events only break follow inside that session , so MVCP
18621862 // compensations and programmatic scrolls never strand a follower.
18631863 const userScrollSessionRef = useRef ( false ) ;
18641864 const setEndFollow = useCallback (
@@ -2173,33 +2173,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
21732173 props . latestTurn ,
21742174 ] ,
21752175 ) ;
2176- const disclosureEnteringEntryIds = useMemo ( ( ) => {
2177- const anchorKey = disclosureAnchorKeyRef . current ;
2178- const previousPresentedFeed = previousPresentedFeedRef . current ;
2179- if ( ! disclosureToggleSettling || anchorKey === null || previousPresentedFeed === null ) {
2180- return EMPTY_DISCLOSURE_ENTRY_IDS ;
2181- }
2182-
2183- const previousIds = new Set ( previousPresentedFeed . map ( ( entry ) => entry . id ) ) ;
2184- const anchorIndex = presentedFeed . findIndex ( ( entry ) => entry . id === anchorKey ) ;
2185- const enteringIds = new Set < string > ( ) ;
2186- if ( anchorIndex < 0 ) {
2187- return enteringIds ;
2188- }
2189- for ( let index = anchorIndex + 1 ; index < presentedFeed . length ; index += 1 ) {
2190- const entryId = presentedFeed [ index ] ! . id ;
2191- if ( previousIds . has ( entryId ) ) {
2192- break ;
2193- }
2194- enteringIds . add ( entryId ) ;
2195- }
2196- return enteringIds ;
2197- } , [ disclosureToggleSettling , presentedFeed ] ) ;
2198-
2199- useLayoutEffect ( ( ) => {
2200- previousPresentedFeedRef . current = presentedFeed ;
2201- } , [ presentedFeed ] ) ;
2202-
22032176 // The empty↔filled key below remounts the list and resets its imperative
22042177 // content-inset override. Seed the fresh instance synchronously with the
22052178 // current overlay height before the scroll integration's next reaction;
@@ -2286,13 +2259,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
22862259 }
22872260 disclosureSettleFrameRef . current = requestAnimationFrame ( ( ) => {
22882261 disclosureSettleSecondFrameRef . current = requestAnimationFrame ( ( ) => {
2262+ // A disclosure can leave the reader above the end without a drag.
2263+ // Reconcile follow before a later layout or resume can re-pin it.
2264+ const listState = props . listRef . current ?. getState ( ) ;
2265+ if ( listState ) {
2266+ transitionEndFollow ( {
2267+ type : "disclosure-settled" ,
2268+ isAtEnd : listState . isAtEnd ,
2269+ userScrollSessionActive : userScrollSessionRef . current ,
2270+ } ) ;
2271+ }
22892272 disclosureAnchorKeyRef . current = null ;
22902273 setDisclosureToggleSettling ( false ) ;
22912274 disclosureSettleFrameRef . current = null ;
22922275 disclosureSettleSecondFrameRef . current = null ;
22932276 } ) ;
22942277 } ) ;
2295- } , [ ] ) ;
2278+ } , [ props . listRef , transitionEndFollow ] ) ;
22962279
22972280 const suspendEndScrollMaintenanceForDisclosure = useCallback ( ( anchorKey : string | null ) => {
22982281 disclosureAnchorKeyRef . current = anchorKey ;
@@ -2433,16 +2416,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24332416 [ expandedWorkRows ] ,
24342417 ) ;
24352418
2419+ // Disclosures can mount existing offscreen rows as well as new work rows.
2420+ // Fade those in after movement; never retain removed rows over replacements.
24362421 const renderItem = useCallback (
24372422 ( info : { item : ThreadFeedEntry ; index : number } ) => (
24382423 < Animated . View
24392424 key = { info . item . id }
2440- entering = {
2441- disclosureEnteringEntryIds . has ( info . item . id )
2442- ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION
2443- : undefined
2444- }
2445- exiting = { THREAD_FEED_DISCLOSURE_EXIT_TRANSITION }
2425+ entering = { disclosureToggleSettling ? THREAD_FEED_DISCLOSURE_ENTER_TRANSITION : undefined }
24462426 >
24472427 { renderFeedEntry ( info , {
24482428 environmentId : props . environmentId ,
@@ -2471,7 +2451,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
24712451 ) ,
24722452 [
24732453 copiedRowId ,
2474- disclosureEnteringEntryIds ,
2454+ disclosureToggleSettling ,
24752455 expandedWorkRows ,
24762456 terminalAssistantMessageIds ,
24772457 unsettledTurnId ,
0 commit comments