@@ -843,7 +843,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
843843 top : number ;
844844 } | null > ( null ) ;
845845 const pendingInteractionAnchorFrameRef = useRef < number | null > ( null ) ;
846- const lastContentHeightRef = useRef ( 0 ) ;
846+ const previousPhaseRef = useRef < ReturnType < typeof derivePhase > > ( "disconnected" ) ;
847+ const previousThreadIdRef = useRef < string | null > ( null ) ;
848+ const turnCompletionScrollUntilRef = useRef ( 0 ) ;
847849 const composerEditorRef = useRef < ComposerPromptEditorHandle > ( null ) ;
848850 const composerFormRef = useRef < HTMLFormElement > ( null ) ;
849851 const composerFormHeightRef = useRef ( 0 ) ;
@@ -2299,18 +2301,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
22992301
23002302 // Auto-scroll on new messages
23012303 const messageCount = timelineMessages . length ;
2302- const scrollMessagesToBottom = useCallback (
2303- ( behavior : ScrollBehavior = "auto" , { enableAutoScroll = false } = { } ) => {
2304- const scrollContainer = messagesScrollRef . current ;
2305- if ( ! scrollContainer ) return ;
2306- scrollContainer . scrollTo ( { top : scrollContainer . scrollHeight , behavior } ) ;
2307- lastKnownScrollTopRef . current = scrollContainer . scrollTop ;
2308- if ( enableAutoScroll ) {
2309- shouldAutoScrollRef . current = true ;
2310- }
2311- } ,
2312- [ ] ,
2313- ) ;
2304+ const hasMessages = messageCount > 0 ;
2305+ const scrollMessagesToBottom = useCallback ( ( behavior : ScrollBehavior = "auto" ) => {
2306+ const scrollContainer = messagesScrollRef . current ;
2307+ if ( ! scrollContainer ) return ;
2308+ scrollContainer . scrollTo ( { top : scrollContainer . scrollHeight , behavior } ) ;
2309+ lastKnownScrollTopRef . current = scrollContainer . scrollTop ;
2310+ shouldAutoScrollRef . current = true ;
2311+ } , [ ] ) ;
23142312 const cancelPendingStickToBottom = useCallback ( ( ) => {
23152313 const pendingFrame = pendingAutoScrollFrameRef . current ;
23162314 if ( pendingFrame === null ) return ;
@@ -2325,8 +2323,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
23252323 } , [ ] ) ;
23262324 const scheduleStickToBottom = useCallback ( ( ) => {
23272325 if ( pendingAutoScrollFrameRef . current !== null ) return ;
2326+ if ( performance . now ( ) < turnCompletionScrollUntilRef . current ) return ;
23282327 pendingAutoScrollFrameRef . current = window . requestAnimationFrame ( ( ) => {
23292328 pendingAutoScrollFrameRef . current = null ;
2329+ if ( performance . now ( ) < turnCompletionScrollUntilRef . current ) return ;
23302330 if ( ! shouldAutoScrollRef . current ) return ;
23312331 if ( pendingUserScrollUpIntentRef . current || isPointerScrollActiveRef . current ) return ;
23322332 scrollMessagesToBottom ( ) ;
@@ -2376,33 +2376,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
23762376 scrollMessagesToBottom ( ) ;
23772377 scheduleStickToBottom ( ) ;
23782378 } , [ cancelPendingStickToBottom , scheduleStickToBottom , scrollMessagesToBottom ] ) ;
2379- const REVEAL_SCROLL_VIEWPORT_FRACTION = 0.4 ;
2380- const onRevealStart = useCallback (
2381- ( messageId : string ) => {
2382- const scrollContainer = messagesScrollRef . current ;
2383- if ( ! scrollContainer || ! shouldAutoScrollRef . current ) return ;
2384- if ( ! isScrollContainerNearBottom ( scrollContainer ) ) return ;
2385-
2386- const rowElement = scrollContainer . querySelector < HTMLElement > (
2387- `[data-row-message-id="${ CSS . escape ( messageId ) } "]` ,
2388- ) ;
2389- if ( ! rowElement ) return ;
2390-
2391- const rowHeight = rowElement . getBoundingClientRect ( ) . height ;
2392- const viewportHeight = scrollContainer . clientHeight ;
2393- if ( rowHeight < viewportHeight * REVEAL_SCROLL_VIEWPORT_FRACTION ) return ;
2394-
2395- cancelPendingStickToBottom ( ) ;
2396- pendingAutoScrollFrameRef . current = null ;
2397-
2398- const rowOffsetTop = rowElement . offsetTop ;
2399- scrollContainer . scrollTo ( { top : rowOffsetTop , behavior : "smooth" } ) ;
2400- lastKnownScrollTopRef . current = scrollContainer . scrollTop ;
2401- shouldAutoScrollRef . current = false ;
2402- setShowScrollToBottom ( true ) ;
2403- } ,
2404- [ cancelPendingStickToBottom ] ,
2405- ) ;
24062379 const onMessagesScroll = useCallback ( ( ) => {
24072380 const scrollContainer = messagesScrollRef . current ;
24082381 if ( ! scrollContainer ) return ;
@@ -2414,18 +2387,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
24142387 pendingUserScrollUpIntentRef . current = false ;
24152388 } else if ( shouldAutoScrollRef . current && pendingUserScrollUpIntentRef . current ) {
24162389 const scrolledUp = currentScrollTop < lastKnownScrollTopRef . current - 1 ;
2417- if ( scrolledUp ) {
2390+ if ( scrolledUp && ! isNearBottom ) {
24182391 shouldAutoScrollRef . current = false ;
2419- pendingUserScrollUpIntentRef . current = false ;
2420- } else if ( ! scrolledUp ) {
2421- pendingUserScrollUpIntentRef . current = false ;
24222392 }
2393+ pendingUserScrollUpIntentRef . current = false ;
24232394 } else if ( shouldAutoScrollRef . current && isPointerScrollActiveRef . current ) {
24242395 const scrolledUp = currentScrollTop < lastKnownScrollTopRef . current - 1 ;
2425- if ( scrolledUp ) {
2396+ if ( scrolledUp && ! isNearBottom ) {
24262397 shouldAutoScrollRef . current = false ;
24272398 }
24282399 } else if ( shouldAutoScrollRef . current && ! isNearBottom ) {
2400+ // Catch-all for keyboard/assistive scroll interactions.
24292401 const scrolledUp = currentScrollTop < lastKnownScrollTopRef . current - 1 ;
24302402 if ( scrolledUp ) {
24312403 shouldAutoScrollRef . current = false ;
@@ -2438,10 +2410,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
24382410 const onMessagesWheel = useCallback ( ( event : React . WheelEvent < HTMLDivElement > ) => {
24392411 if ( event . deltaY < 0 ) {
24402412 pendingUserScrollUpIntentRef . current = true ;
2441- const scrollContainer = messagesScrollRef . current ;
2442- if ( scrollContainer ) {
2443- scrollContainer . scrollTo ( { top : scrollContainer . scrollTop } ) ;
2444- }
24452413 }
24462414 } , [ ] ) ;
24472415 const onMessagesPointerDown = useCallback ( ( _event : React . PointerEvent < HTMLDivElement > ) => {
@@ -2479,18 +2447,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
24792447 useLayoutEffect ( ( ) => {
24802448 if ( ! activeThread ?. id ) return ;
24812449 shouldAutoScrollRef . current = true ;
2482- scrollMessagesToBottom ( ) ;
2483- const delays = [ 50 , 150 , 300 ] ;
2484- const timeouts = delays . map ( ( delay ) =>
2485- window . setTimeout ( ( ) => {
2486- if ( ! shouldAutoScrollRef . current ) return ;
2487- scrollMessagesToBottom ( ) ;
2488- } , delay ) ,
2489- ) ;
2450+ scheduleStickToBottom ( ) ;
2451+ const timeout = window . setTimeout ( ( ) => {
2452+ const scrollContainer = messagesScrollRef . current ;
2453+ if ( ! scrollContainer ) return ;
2454+ if ( isScrollContainerNearBottom ( scrollContainer ) ) return ;
2455+ scheduleStickToBottom ( ) ;
2456+ } , 96 ) ;
24902457 return ( ) => {
2491- for ( const timeout of timeouts ) window . clearTimeout ( timeout ) ;
2458+ window . clearTimeout ( timeout ) ;
24922459 } ;
2493- } , [ activeThread ?. id , scrollMessagesToBottom ] ) ;
2460+ } , [ activeThread ?. id , scheduleStickToBottom ] ) ;
24942461 useLayoutEffect ( ( ) => {
24952462 const composerForm = composerFormRef . current ;
24962463 if ( ! composerForm ) return ;
@@ -2574,34 +2541,74 @@ export default function ChatView({ threadId }: ChatViewProps) {
25742541 if ( ! shouldAutoScrollRef . current ) return ;
25752542 scheduleStickToBottom ( ) ;
25762543 } , [ phase , scheduleStickToBottom , timelineEntries ] ) ;
2577- useLayoutEffect ( ( ) => {
2578- if ( ! messagesScrollElement || typeof ResizeObserver === "undefined" ) return ;
2579- const contentElement = messagesScrollElement . firstElementChild as HTMLElement | null ;
2580- if ( ! contentElement ) return ;
2544+ useEffect ( ( ) => {
2545+ const scrollContainer = messagesScrollRef . current ;
2546+ if ( ! scrollContainer ) return ;
2547+ if ( typeof ResizeObserver === "undefined" ) return ;
2548+
2549+ const timelineRoot = scrollContainer . querySelector < HTMLElement > ( '[data-timeline-root="true"]' ) ;
2550+ if ( ! timelineRoot ) return ;
25812551
2582- lastContentHeightRef . current = contentElement . getBoundingClientRect ( ) . height ;
2552+ let previousHeight = timelineRoot . getBoundingClientRect ( ) . height ;
25832553
25842554 const observer = new ResizeObserver ( ( entries ) => {
2585- const [ entry ] = entries ;
2555+ const entry = entries [ 0 ] ;
25862556 if ( ! entry ) return ;
2557+
25872558 const nextHeight = entry . contentRect . height ;
2588- const previousHeight = lastContentHeightRef . current ;
2589- lastContentHeightRef . current = nextHeight ;
2559+ if ( nextHeight <= previousHeight + 0.5 ) {
2560+ previousHeight = nextHeight ;
2561+ return ;
2562+ }
2563+ previousHeight = nextHeight ;
25902564
2591- if ( nextHeight <= previousHeight ) return ;
25922565 if ( ! shouldAutoScrollRef . current ) return ;
2593- if ( pendingInteractionAnchorRef . current ) return ;
2594- if ( pendingUserScrollUpIntentRef . current || isPointerScrollActiveRef . current ) return ;
2595- cancelPendingStickToBottom ( ) ;
2596- pendingAutoScrollFrameRef . current = null ;
2597- scrollMessagesToBottom ( ) ;
2566+ scheduleStickToBottom ( ) ;
25982567 } ) ;
25992568
2600- observer . observe ( contentElement ) ;
2601- return ( ) => {
2602- observer . disconnect ( ) ;
2569+ observer . observe ( timelineRoot ) ;
2570+ return ( ) => observer . disconnect ( ) ;
2571+ } , [ activeThread ?. id , hasMessages , scheduleStickToBottom ] ) ;
2572+ useEffect ( ( ) => {
2573+ if ( phase !== "running" ) return ;
2574+ const intervalId = window . setInterval ( ( ) => {
2575+ if ( ! shouldAutoScrollRef . current ) return ;
2576+ const scrollContainer = messagesScrollRef . current ;
2577+ if ( ! scrollContainer ) return ;
2578+ if ( ! isScrollContainerNearBottom ( scrollContainer ) ) {
2579+ scrollMessagesToBottom ( ) ;
2580+ }
2581+ } , 150 ) ;
2582+ return ( ) => window . clearInterval ( intervalId ) ;
2583+ } , [ phase , scrollMessagesToBottom ] ) ;
2584+ useEffect ( ( ) => {
2585+ const prevPhase = previousPhaseRef . current ;
2586+ const prevThreadId = previousThreadIdRef . current ;
2587+ previousPhaseRef . current = phase ;
2588+ previousThreadIdRef . current = activeThreadId ;
2589+ if ( prevThreadId !== activeThreadId ) return ;
2590+ if ( prevPhase !== "running" || phase === "running" ) return ;
2591+ if ( ! shouldAutoScrollRef . current ) return ;
2592+ const assistantMessageId = activeLatestTurn ?. assistantMessageId ;
2593+ if ( ! assistantMessageId ) return ;
2594+ const scrollContainer = messagesScrollRef . current ;
2595+ if ( ! scrollContainer ) return ;
2596+ const selector = `[data-row-message-id="${ CSS . escape ( assistantMessageId ) } "]` ;
2597+ turnCompletionScrollUntilRef . current = performance . now ( ) + 1200 ;
2598+ cancelPendingStickToBottom ( ) ;
2599+ let attempts = 0 ;
2600+ const tryScroll = ( ) => {
2601+ const messageRow = scrollContainer . querySelector < HTMLElement > ( selector ) ;
2602+ if ( messageRow ) {
2603+ messageRow . scrollIntoView ( { block : "start" , behavior : "smooth" } ) ;
2604+ return ;
2605+ }
2606+ if ( ++ attempts < 10 ) {
2607+ requestAnimationFrame ( tryScroll ) ;
2608+ }
26032609 } ;
2604- } , [ messagesScrollElement , activeThread ?. id , cancelPendingStickToBottom , scrollMessagesToBottom ] ) ;
2610+ requestAnimationFrame ( tryScroll ) ;
2611+ } , [ activeLatestTurn ?. assistantMessageId , activeThreadId , cancelPendingStickToBottom , phase ] ) ;
26052612
26062613 useEffect ( ( ) => {
26072614 setExpandedWorkGroups ( { } ) ;
@@ -4742,7 +4749,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
47424749 onCancelEditUserMessage = { discardUserMessageEditSession }
47434750 onSubmitEditUserMessage = { onSubmitEditUserMessage }
47444751 onReplyToSelection = { onReplyToSelection }
4745- onRevealStart = { onRevealStart }
47464752 />
47474753 </ div >
47484754
@@ -4751,7 +4757,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
47514757 < div className = "pointer-events-none absolute bottom-1 left-1/2 z-30 flex -translate-x-1/2 justify-center py-1.5" >
47524758 < button
47534759 type = "button"
4754- onClick = { ( ) => scrollMessagesToBottom ( "smooth" , { enableAutoScroll : true } ) }
4760+ onClick = { ( ) => scrollMessagesToBottom ( "smooth" ) }
47554761 className = "pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 bg-card px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer"
47564762 >
47574763 < ChevronDownIcon className = "size-3.5" />
0 commit comments