@@ -5,72 +5,19 @@ import clsx from 'clsx'
55import { ChevronUp } from 'lucide-react'
66
77/**
8- * Timer update interval in milliseconds
8+ * Max height for thinking content before internal scrolling kicks in
99 */
10- const TIMER_UPDATE_INTERVAL = 100
10+ const THINKING_MAX_HEIGHT = 125
1111
1212/**
13- * Milliseconds threshold for displaying as seconds
13+ * Interval for auto-scroll during streaming (ms)
1414 */
15- const SECONDS_THRESHOLD = 1000
15+ const SCROLL_INTERVAL = 100
1616
1717/**
18- * Props for the ShimmerOverlayText component
19- */
20- interface ShimmerOverlayTextProps {
21- /** Label text to display */
22- label : string
23- /** Value text to display */
24- value : string
25- /** Whether the shimmer animation is active */
26- active ?: boolean
27- }
28-
29- /**
30- * ShimmerOverlayText component for thinking block
31- * Applies shimmer effect to the "Thought for X.Xs" text during streaming
32- *
33- * @param props - Component props
34- * @returns Text with optional shimmer overlay effect
18+ * Timer update interval in milliseconds
3519 */
36- function ShimmerOverlayText ( { label, value, active = false } : ShimmerOverlayTextProps ) {
37- return (
38- < span className = 'relative inline-block' >
39- < span className = 'text-[var(--text-tertiary)]' > { label } </ span >
40- < span className = 'text-[var(--text-muted)]' > { value } </ span >
41- { active ? (
42- < span
43- aria-hidden = 'true'
44- className = 'pointer-events-none absolute inset-0 select-none overflow-hidden'
45- >
46- < span
47- className = 'block text-transparent'
48- style = { {
49- backgroundImage :
50- 'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)' ,
51- backgroundSize : '200% 100%' ,
52- backgroundRepeat : 'no-repeat' ,
53- WebkitBackgroundClip : 'text' ,
54- backgroundClip : 'text' ,
55- animation : 'thinking-shimmer 1.4s ease-in-out infinite' ,
56- mixBlendMode : 'screen' ,
57- } }
58- >
59- { label }
60- { value }
61- </ span >
62- </ span >
63- ) : null }
64- < style > { `
65- @keyframes thinking-shimmer {
66- 0% { background-position: 150% 0; }
67- 50% { background-position: 0% 0; }
68- 100% { background-position: -150% 0; }
69- }
70- ` } </ style >
71- </ span >
72- )
73- }
20+ const TIMER_UPDATE_INTERVAL = 100
7421
7522/**
7623 * Props for the ThinkingBlock component
@@ -80,46 +27,37 @@ interface ThinkingBlockProps {
8027 content : string
8128 /** Whether the block is currently streaming */
8229 isStreaming ?: boolean
83- /** Persisted duration from content block */
84- duration ?: number
85- /** Persisted start time from content block */
86- startTime ?: number
30+ /** Whether there are more content blocks after this one (e.g., tool calls) */
31+ hasFollowingContent ?: boolean
8732}
8833
8934/**
9035 * ThinkingBlock component displays AI reasoning/thinking process
9136 * Shows collapsible content with duration timer
9237 * Auto-expands during streaming and collapses when complete
38+ * Auto-collapses when a tool call or other content comes in after it
9339 *
9440 * @param props - Component props
9541 * @returns Thinking block with expandable content and timer
9642 */
9743export function ThinkingBlock ( {
9844 content,
9945 isStreaming = false ,
100- duration : persistedDuration ,
101- startTime : persistedStartTime ,
46+ hasFollowingContent = false ,
10247} : ThinkingBlockProps ) {
10348 const [ isExpanded , setIsExpanded ] = useState ( false )
104- const [ duration , setDuration ] = useState ( persistedDuration ?? 0 )
49+ const [ duration , setDuration ] = useState ( 0 )
10550 const userCollapsedRef = useRef < boolean > ( false )
106- const startTimeRef = useRef < number > ( persistedStartTime ?? Date . now ( ) )
107-
108- /**
109- * Updates start time reference when persisted start time changes
110- */
111- useEffect ( ( ) => {
112- if ( typeof persistedStartTime === 'number' ) {
113- startTimeRef . current = persistedStartTime
114- }
115- } , [ persistedStartTime ] )
51+ const scrollContainerRef = useRef < HTMLDivElement > ( null )
52+ const startTimeRef = useRef < number > ( Date . now ( ) )
11653
11754 /**
11855 * Auto-expands block when streaming with content
119- * Auto-collapses when streaming ends
56+ * Auto-collapses when streaming ends OR when following content arrives
12057 */
12158 useEffect ( ( ) => {
122- if ( ! isStreaming ) {
59+ // Collapse if streaming ended or if there's following content (like a tool call)
60+ if ( ! isStreaming || hasFollowingContent ) {
12361 setIsExpanded ( false )
12462 userCollapsedRef . current = false
12563 return
@@ -128,42 +66,57 @@ export function ThinkingBlock({
12866 if ( ! userCollapsedRef . current && content && content . trim ( ) . length > 0 ) {
12967 setIsExpanded ( true )
13068 }
131- } , [ isStreaming , content ] )
69+ } , [ isStreaming , content , hasFollowingContent ] )
13270
133- /**
134- * Updates duration timer during streaming
135- * Uses persisted duration when available
136- */
71+ // Reset start time when streaming begins
13772 useEffect ( ( ) => {
138- if ( typeof persistedDuration === 'number' ) {
139- setDuration ( persistedDuration )
140- return
73+ if ( isStreaming && ! hasFollowingContent ) {
74+ startTimeRef . current = Date . now ( )
75+ setDuration ( 0 )
14176 }
77+ } , [ isStreaming , hasFollowingContent ] )
14278
143- if ( isStreaming ) {
144- const interval = setInterval ( ( ) => {
145- setDuration ( Date . now ( ) - startTimeRef . current )
146- } , TIMER_UPDATE_INTERVAL )
147- return ( ) => clearInterval ( interval )
148- }
79+ // Update duration timer during streaming (stop when following content arrives)
80+ useEffect ( ( ) => {
81+ // Stop timer if not streaming or if there's following content (thinking is done)
82+ if ( ! isStreaming || hasFollowingContent ) return
83+
84+ const interval = setInterval ( ( ) => {
85+ setDuration ( Date . now ( ) - startTimeRef . current )
86+ } , TIMER_UPDATE_INTERVAL )
14987
150- setDuration ( Date . now ( ) - startTimeRef . current )
151- } , [ isStreaming , persistedDuration ] )
88+ return ( ) => clearInterval ( interval )
89+ } , [ isStreaming , hasFollowingContent ] )
90+
91+ // Auto-scroll to bottom during streaming using interval (same as copilot chat)
92+ useEffect ( ( ) => {
93+ if ( ! isStreaming || ! isExpanded ) return
94+
95+ const intervalId = window . setInterval ( ( ) => {
96+ const container = scrollContainerRef . current
97+ if ( ! container ) return
98+
99+ container . scrollTo ( {
100+ top : container . scrollHeight ,
101+ behavior : 'smooth' ,
102+ } )
103+ } , SCROLL_INTERVAL )
104+
105+ return ( ) => window . clearInterval ( intervalId )
106+ } , [ isStreaming , isExpanded ] )
152107
153108 /**
154- * Formats duration in milliseconds to human-readable format
155- * @param ms - Duration in milliseconds
156- * @returns Formatted string (e.g., "150ms" or "2.5s")
109+ * Formats duration in milliseconds to seconds
110+ * Always shows seconds, rounded to nearest whole second, minimum 1s
157111 */
158112 const formatDuration = ( ms : number ) => {
159- if ( ms < SECONDS_THRESHOLD ) {
160- return `${ ms } ms`
161- }
162- const seconds = ( ms / SECONDS_THRESHOLD ) . toFixed ( 1 )
113+ const seconds = Math . max ( 1 , Math . round ( ms / 1000 ) )
163114 return `${ seconds } s`
164115 }
165116
166117 const hasContent = content && content . trim ( ) . length > 0
118+ const label = isStreaming ? 'Thinking' : 'Thought'
119+ const durationText = ` for ${ formatDuration ( duration ) } `
167120
168121 return (
169122 < div className = 'mt-1 mb-0' >
@@ -180,21 +133,54 @@ export function ThinkingBlock({
180133 type = 'button'
181134 disabled = { ! hasContent }
182135 >
183- < ShimmerOverlayText
184- label = 'Thought'
185- value = { ` for ${ formatDuration ( duration ) } ` }
186- active = { isStreaming }
187- />
136+ < span className = 'relative inline-block' >
137+ < span className = 'text-[var(--text-tertiary)]' > { label } </ span >
138+ < span className = 'text-[var(--text-muted)]' > { durationText } </ span >
139+ { isStreaming && (
140+ < span
141+ aria-hidden = 'true'
142+ className = 'pointer-events-none absolute inset-0 select-none overflow-hidden'
143+ >
144+ < span
145+ className = 'block text-transparent'
146+ style = { {
147+ backgroundImage :
148+ 'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)' ,
149+ backgroundSize : '200% 100%' ,
150+ backgroundRepeat : 'no-repeat' ,
151+ WebkitBackgroundClip : 'text' ,
152+ backgroundClip : 'text' ,
153+ animation : 'thinking-shimmer 1.4s ease-in-out infinite' ,
154+ mixBlendMode : 'screen' ,
155+ } }
156+ >
157+ { label }
158+ { durationText }
159+ </ span >
160+ </ span >
161+ ) }
162+ < style > { `
163+ @keyframes thinking-shimmer {
164+ 0% { background-position: 150% 0; }
165+ 50% { background-position: 0% 0; }
166+ 100% { background-position: -150% 0; }
167+ }
168+ ` } </ style >
169+ </ span >
188170 { hasContent && (
189171 < ChevronUp
190- className = { clsx ( 'h-3 w-3 transition-transform' , isExpanded && 'rotate-180' ) }
172+ className = { clsx ( 'h-3 w-3 transition-transform' , isExpanded ? 'rotate-180' : 'rotate-90 ') }
191173 aria-hidden = 'true'
192174 />
193175 ) }
194176 </ button >
195177
196178 { isExpanded && (
197- < div className = 'ml-1 border-[var(--border-1)] border-l-2 pl-2' >
179+ < div
180+ ref = { scrollContainerRef }
181+ className = 'ml-1 overflow-y-auto border-[var(--border-1)] border-l-2 pl-2'
182+ style = { { maxHeight : THINKING_MAX_HEIGHT } }
183+ >
198184 < pre className = 'whitespace-pre-wrap font-[470] font-season text-[12px] text-[var(--text-tertiary)] leading-[1.15rem]' >
199185 { content }
200186 { isStreaming && (
0 commit comments