@@ -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