11'use client'
22
33import { type KeyboardEvent , useCallback , useEffect , useMemo , useRef , useState } from 'react'
4- import { ArrowUp } from 'lucide-react'
4+ import { ArrowDown , ArrowUp } from 'lucide-react'
55import { Button } from '@/components/ui/button'
66import { Input } from '@/components/ui/input'
77import { ScrollArea } from '@/components/ui/scroll-area'
@@ -42,6 +42,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
4242 } = useChatStore ( )
4343 const { entries } = useConsoleStore ( )
4444 const messagesEndRef = useRef < HTMLDivElement > ( null )
45+ const scrollAreaRef = useRef < HTMLDivElement > ( null )
4546 const inputRef = useRef < HTMLInputElement > ( null )
4647 const timeoutRef = useRef < NodeJS . Timeout | null > ( null )
4748 const abortControllerRef = useRef < AbortController | null > ( null )
@@ -50,6 +51,10 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
5051 const [ promptHistory , setPromptHistory ] = useState < string [ ] > ( [ ] )
5152 const [ historyIndex , setHistoryIndex ] = useState ( - 1 )
5253
54+ // Scroll state
55+ const [ isNearBottom , setIsNearBottom ] = useState ( true )
56+ const [ showScrollButton , setShowScrollButton ] = useState ( false )
57+
5358 // Use the execution store state to track if a workflow is executing
5459 const { isExecuting } = useExecutionStore ( )
5560
@@ -125,6 +130,31 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
125130 } , delay )
126131 } , [ ] )
127132
133+ // Scroll to bottom function
134+ const scrollToBottom = useCallback ( ( ) => {
135+ if ( messagesEndRef . current ) {
136+ messagesEndRef . current . scrollIntoView ( { behavior : 'smooth' } )
137+ }
138+ } , [ ] )
139+
140+ // Handle scroll events to track user position
141+ const handleScroll = useCallback ( ( ) => {
142+ const scrollArea = scrollAreaRef . current
143+ if ( ! scrollArea ) return
144+
145+ // Find the viewport element inside the ScrollArea
146+ const viewport = scrollArea . querySelector ( '[data-radix-scroll-area-viewport]' )
147+ if ( ! viewport ) return
148+
149+ const { scrollTop, scrollHeight, clientHeight } = viewport
150+ const distanceFromBottom = scrollHeight - scrollTop - clientHeight
151+
152+ // Consider "near bottom" if within 100px of bottom
153+ const nearBottom = distanceFromBottom <= 100
154+ setIsNearBottom ( nearBottom )
155+ setShowScrollButton ( ! nearBottom )
156+ } , [ ] )
157+
128158 // Cleanup on unmount
129159 useEffect ( ( ) => {
130160 return ( ) => {
@@ -137,12 +167,47 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
137167 }
138168 } , [ ] )
139169
140- // Auto- scroll to bottom when new messages are added
170+ // Attach scroll listener
141171 useEffect ( ( ) => {
142- if ( messagesEndRef . current ) {
172+ const scrollArea = scrollAreaRef . current
173+ if ( ! scrollArea ) return
174+
175+ // Find the viewport element inside the ScrollArea
176+ const viewport = scrollArea . querySelector ( '[data-radix-scroll-area-viewport]' )
177+ if ( ! viewport ) return
178+
179+ viewport . addEventListener ( 'scroll' , handleScroll , { passive : true } )
180+
181+ // Also listen for scrollend event if available (for smooth scrolling)
182+ if ( 'onscrollend' in viewport ) {
183+ viewport . addEventListener ( 'scrollend' , handleScroll , { passive : true } )
184+ }
185+
186+ // Initial scroll state check with small delay to ensure DOM is ready
187+ setTimeout ( handleScroll , 100 )
188+
189+ return ( ) => {
190+ viewport . removeEventListener ( 'scroll' , handleScroll )
191+ if ( 'onscrollend' in viewport ) {
192+ viewport . removeEventListener ( 'scrollend' , handleScroll )
193+ }
194+ }
195+ } , [ handleScroll ] )
196+
197+ // Auto-scroll to bottom when new messages are added, but only if user is near bottom
198+ // Exception: Always scroll when sending a new message
199+ useEffect ( ( ) => {
200+ if ( workflowMessages . length === 0 ) return
201+
202+ const lastMessage = workflowMessages [ workflowMessages . length - 1 ]
203+ const isNewUserMessage = lastMessage ?. type === 'user'
204+
205+ // Always scroll for new user messages, or only if near bottom for assistant messages
206+ if ( ( isNewUserMessage || isNearBottom ) && messagesEndRef . current ) {
143207 messagesEndRef . current . scrollIntoView ( { behavior : 'smooth' } )
208+ // Let the scroll event handler update the state naturally after animation completes
144209 }
145- } , [ workflowMessages ] )
210+ } , [ workflowMessages , isNearBottom ] )
146211
147212 // Handle send message
148213 const handleSendMessage = useCallback ( async ( ) => {
@@ -449,7 +514,7 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
449514 No messages yet
450515 </ div >
451516 ) : (
452- < ScrollArea className = 'h-full pb-2' hideScrollbar = { true } >
517+ < ScrollArea ref = { scrollAreaRef } className = 'h-full pb-2' hideScrollbar = { true } >
453518 < div >
454519 { workflowMessages . map ( ( message ) => (
455520 < ChatMessage key = { message . id } message = { message } />
@@ -458,6 +523,21 @@ export function Chat({ panelWidth, chatMessage, setChatMessage }: ChatProps) {
458523 </ div >
459524 </ ScrollArea >
460525 ) }
526+
527+ { /* Scroll to bottom button */ }
528+ { showScrollButton && (
529+ < div className = '-translate-x-1/2 absolute bottom-20 left-1/2 z-10' >
530+ < Button
531+ onClick = { scrollToBottom }
532+ size = 'sm'
533+ variant = 'outline'
534+ className = 'flex items-center gap-1 rounded-full border border-gray-200 bg-white px-3 py-1 shadow-lg transition-all hover:bg-gray-50'
535+ >
536+ < ArrowDown className = 'h-3.5 w-3.5' />
537+ < span className = 'sr-only' > Scroll to bottom</ span >
538+ </ Button >
539+ </ div >
540+ ) }
461541 </ div >
462542
463543 { /* Input section - Fixed height */ }
0 commit comments