@@ -6,38 +6,29 @@ import {
66} from "@/components/ui/collapsible" ;
77import { useState , useRef , useEffect , useCallback } from "react" ;
88import { TextShimmer } from "@/components/ui/text-shimmer" ;
9+ import {
10+ advanceThinkingAnimationState ,
11+ createThinkingAnimationState ,
12+ } from "@/lib/thinking-animation" ;
913
1014interface ThinkingBlockProps {
1115 thinking : string ;
1216 isStreaming ?: boolean ;
1317 thinkingComplete ?: boolean ;
1418}
1519
16- interface AnimatedChunk {
17- id : number ;
18- text : string ;
19- }
20-
21- function commonPrefixLength ( a : string , b : string ) : number {
22- const max = Math . min ( a . length , b . length ) ;
23- let i = 0 ;
24- while ( i < max && a . charCodeAt ( i ) === b . charCodeAt ( i ) ) i += 1 ;
25- return i ;
26- }
27-
2820export function ThinkingBlock ( { thinking, isStreaming, thinkingComplete } : ThinkingBlockProps ) {
2921 const [ open , setOpen ] = useState ( false ) ;
3022 const contentRef = useRef < HTMLDivElement > ( null ) ;
3123 // Tracks whether user manually scrolled up in the inner thinking div
3224 const userScrolledRef = useRef ( false ) ;
3325 const isThinking = isStreaming && ! thinkingComplete && thinking . length > 0 ;
3426
35- // Render thinking as stable prefix + appended animated chunks.
36- // This keeps earlier chunk animations running even when new chunks arrive.
37- const prevThinkingRef = useRef ( thinking ) ;
38- const nextChunkIdRef = useRef ( 0 ) ;
39- const [ baseText , setBaseText ] = useState ( thinking ) ;
40- const [ animatedChunks , setAnimatedChunks ] = useState < AnimatedChunk [ ] > ( [ ] ) ;
27+ // Keep the animation state simple and append-only. The v0.19.0 coalescing
28+ // timer introduced replay/duplication under rapid thinking updates.
29+ const [ animationState , setAnimationState ] = useState ( ( ) =>
30+ createThinkingAnimationState ( thinking ) ,
31+ ) ;
4132
4233 const handleScroll = useCallback ( ( ) => {
4334 const el = contentRef . current ;
@@ -57,106 +48,11 @@ export function ThinkingBlock({ thinking, isStreaming, thinkingComplete }: Think
5748 } , [ thinking , open ] ) ;
5849
5950 useEffect ( ( ) => {
60- const prev = prevThinkingRef . current ;
61- const curr = thinking ;
62- prevThinkingRef . current = curr ;
63-
64- if ( ! isThinking ) {
65- setBaseText ( curr ) ;
66- setAnimatedChunks ( [ ] ) ;
67- return ;
68- }
69-
70- if ( ! prev || ! curr ) {
71- setBaseText ( curr ) ;
72- setAnimatedChunks ( [ ] ) ;
73- return ;
74- }
75-
76- const prefixLen = commonPrefixLength ( prev , curr ) ;
77- const appendedLen = curr . length - prefixLen ;
78- if ( appendedLen <= 0 ) {
79- setBaseText ( curr ) ;
80- setAnimatedChunks ( [ ] ) ;
81- return ;
82- }
83-
84- // If upstream rewrites existing text, reset to avoid animating old regions.
85- const changedInMiddle = prefixLen < prev . length ;
86- if ( changedInMiddle ) {
87- setBaseText ( curr ) ;
88- setAnimatedChunks ( [ ] ) ;
89- return ;
90- }
91-
92- const appended = curr . slice ( prev . length ) ;
93- if ( ! appended ) return ;
94-
95- setAnimatedChunks ( ( chunks ) => [
96- ...chunks ,
97- { id : nextChunkIdRef . current ++ , text : appended } ,
98- ] ) ;
51+ setAnimationState ( ( prev ) =>
52+ advanceThinkingAnimationState ( prev , thinking , isThinking ) ,
53+ ) ;
9954 } , [ thinking , isThinking ] ) ;
10055
101- // Coalesce completed animation chunks back into baseText every 500ms
102- // to keep the animated <span> count bounded (~20-30 max)
103- useEffect ( ( ) => {
104- if ( ! isThinking ) return ;
105-
106- const COALESCE_INTERVAL = 500 ;
107- const ANIMATION_DURATION = 400 ; // chunks older than this are "done"
108-
109- // Track when each chunk was added
110- const chunkTimestamps = new Map < number , number > ( ) ;
111-
112- const interval = setInterval ( ( ) => {
113- const now = Date . now ( ) ;
114-
115- setAnimatedChunks ( ( chunks ) => {
116- if ( chunks . length === 0 ) return chunks ;
117-
118- // Record timestamps for new chunks
119- for ( const chunk of chunks ) {
120- if ( ! chunkTimestamps . has ( chunk . id ) ) {
121- chunkTimestamps . set ( chunk . id , now ) ;
122- }
123- }
124-
125- // Find the last completed chunk index
126- let lastCompleted = - 1 ;
127- for ( let i = 0 ; i < chunks . length ; i ++ ) {
128- const ts = chunkTimestamps . get ( chunks [ i ] . id ) ?? now ;
129- if ( now - ts >= ANIMATION_DURATION ) {
130- lastCompleted = i ;
131- } else {
132- break ; // chunks are in order, so stop at first incomplete
133- }
134- }
135-
136- if ( lastCompleted < 0 ) return chunks ;
137-
138- // Merge completed chunks into baseText
139- const completedText = chunks
140- . slice ( 0 , lastCompleted + 1 )
141- . map ( ( c ) => c . text )
142- . join ( "" ) ;
143-
144- // Clean up timestamps for merged chunks
145- for ( let i = 0 ; i <= lastCompleted ; i ++ ) {
146- chunkTimestamps . delete ( chunks [ i ] . id ) ;
147- }
148-
149- setBaseText ( ( prev ) => prev + completedText ) ;
150- return chunks . slice ( lastCompleted + 1 ) ;
151- } ) ;
152- } , COALESCE_INTERVAL ) ;
153-
154- return ( ) => {
155- clearInterval ( interval ) ;
156- chunkTimestamps . clear ( ) ;
157- } ;
158- } , [ isThinking ] ) ;
159-
16056 const handleOpenChange = useCallback ( ( isOpen : boolean ) => {
16157 setOpen ( isOpen ) ;
16258 if ( isOpen ) {
@@ -189,8 +85,8 @@ export function ThinkingBlock({ thinking, isStreaming, thinkingComplete }: Think
18985 onScroll = { handleScroll }
19086 className = "mt-1 mb-2 max-h-60 overflow-auto border-s-2 border-foreground/10 ps-3 py-1 text-xs text-foreground/40 whitespace-pre-wrap"
19187 >
192- { baseText }
193- { animatedChunks . map ( ( chunk ) => (
88+ { animationState . baseText }
89+ { animationState . animatedChunks . map ( ( chunk ) => (
19490 < span key = { chunk . id } className = "stream-chunk-enter" > { chunk . text } </ span >
19591 ) ) }
19692 </ div >
0 commit comments