@@ -150,51 +150,113 @@ function handleContainerScroll() {
150150 lastScrollTop .value = currentScrollTop
151151}
152152
153- // Extra listeners to detect explicit user interactions that should disable auto-scroll.
154- // These help when the user uses the wheel, touch, keyboard or drags the scrollbar.
155- function disableAutoScrollOnUserInteraction(e ? : Event | WheelEvent | TouchEvent | PointerEvent | KeyboardEvent ) {
153+ // Track touch/pointer start positions to detect direction
154+ const touchStartY = ref <number | null >(null )
155+ const pointerStartY = ref <number | null >(null )
156+
157+ // Wheel: only disable auto-scroll when user scrolls up (deltaY < 0).
158+ function handleWheel(e : WheelEvent ) {
156159 try {
157- // If it's a wheel event and it scrolls down while already at bottom, ignore.
158- if (e && ' deltaY' in (e as WheelEvent )) {
159- const we = e as WheelEvent
160- if (messagesContainer .value && we .deltaY > 0 && isAtBottom (messagesContainer .value )) {
161- return
162- }
160+ if (! messagesContainer .value )
161+ return
162+
163+ // User scrolled up (want older content)
164+ if (e .deltaY < 0 ) {
165+ autoScrollEnabled .value = false
166+ }
167+ else {
168+ // Scrolling down: if near bottom, re-enable
169+ if (isAtBottom (messagesContainer .value ))
170+ autoScrollEnabled .value = true
163171 }
164172 }
165173 catch {
166174 // ignore
167175 }
176+ }
177+
178+ // Touch handlers: detect move direction between touchstart and touchmove
179+ function handleTouchStart(e : TouchEvent ) {
180+ if (e .touches && e .touches .length > 0 ) {
181+ touchStartY .value = e .touches [0 ].clientY
182+ }
183+ }
168184
169- autoScrollEnabled .value = false
185+ function handleTouchMove(e : TouchEvent ) {
186+ if (! messagesContainer .value || touchStartY .value == null || ! e .touches || e .touches .length === 0 )
187+ return
188+
189+ const currentY = e .touches [0 ].clientY
190+ const delta = currentY - touchStartY .value
191+ // Positive delta means finger moved down -> content scrolls up (towards top) -> user viewing earlier content
192+ if (delta > 0 ) {
193+ autoScrollEnabled .value = false
194+ }
195+ else {
196+ if (isAtBottom (messagesContainer .value ))
197+ autoScrollEnabled .value = true
198+ }
170199}
171200
172- // Keyboard interactions that imply user navigation (PageUp, ArrowUp, Home, etc.)
201+ // Pointer handlers for scrollbar drag / pointer-based dragging
202+ function handlePointerDown(e : PointerEvent ) {
203+ pointerStartY .value = (e as PointerEvent ).clientY
204+ // Attach move/up listeners to document to track the drag
205+ const move = (ev : PointerEvent ) => {
206+ if (pointerStartY .value == null )
207+ return
208+ const delta = ev .clientY - pointerStartY .value
209+ if (delta > 0 ) {
210+ autoScrollEnabled .value = false
211+ }
212+ else {
213+ if (messagesContainer .value && isAtBottom (messagesContainer .value ))
214+ autoScrollEnabled .value = true
215+ }
216+ }
217+
218+ const up = () => {
219+ document .removeEventListener (' pointermove' , move )
220+ document .removeEventListener (' pointerup' , up )
221+ pointerStartY .value = null
222+ }
223+
224+ document .addEventListener (' pointermove' , move )
225+ document .addEventListener (' pointerup' , up )
226+ }
227+
228+ // Keyboard interactions: only treat upward navigation as disabling; downward navigation may re-enable when near bottom.
173229function handleKeyDown(e : KeyboardEvent ) {
174- const keysThatMove = [' PageUp' , ' PageDown' , ' ArrowUp' , ' ArrowDown' , ' Home' , ' End' , ' Space' ]
175- if (keysThatMove .includes (e .key )) {
176- disableAutoScrollOnUserInteraction (e )
230+ const upKeys = [' PageUp' , ' ArrowUp' , ' Home' ]
231+ const downKeys = [' PageDown' , ' ArrowDown' , ' End' , ' Space' ]
232+ if (upKeys .includes (e .key )) {
233+ autoScrollEnabled .value = false
234+ }
235+ else if (downKeys .includes (e .key )) {
236+ if (messagesContainer .value && isAtBottom (messagesContainer .value ))
237+ autoScrollEnabled .value = true
177238 }
178239}
179240
180241onMounted (() => {
181242 // Initialize lastScrollTop and attach extra listeners
182243 if (messagesContainer .value ) {
183244 lastScrollTop .value = messagesContainer .value .scrollTop
184-
185- messagesContainer .value .addEventListener (' wheel ' , disableAutoScrollOnUserInteraction , { passive: true })
186- messagesContainer .value .addEventListener (' touchstart ' , disableAutoScrollOnUserInteraction , { passive: true })
187- messagesContainer .value .addEventListener (' pointerdown' , disableAutoScrollOnUserInteraction )
245+ messagesContainer . value . addEventListener ( ' wheel ' , handleWheel , { passive: true })
246+ messagesContainer .value .addEventListener (' touchstart ' , handleTouchStart , { passive: true })
247+ messagesContainer .value .addEventListener (' touchmove ' , handleTouchMove , { passive: true })
248+ messagesContainer .value .addEventListener (' pointerdown' , handlePointerDown )
188249 // keydown could be on document
189250 document .addEventListener (' keydown' , handleKeyDown )
190251 }
191252})
192253
193254onUnmounted (() => {
194255 if (messagesContainer .value ) {
195- messagesContainer .value .removeEventListener (' wheel' , disableAutoScrollOnUserInteraction )
196- messagesContainer .value .removeEventListener (' touchstart' , disableAutoScrollOnUserInteraction )
197- messagesContainer .value .removeEventListener (' pointerdown' , disableAutoScrollOnUserInteraction )
256+ messagesContainer .value .removeEventListener (' wheel' , handleWheel )
257+ messagesContainer .value .removeEventListener (' touchstart' , handleTouchStart )
258+ messagesContainer .value .removeEventListener (' touchmove' , handleTouchMove )
259+ messagesContainer .value .removeEventListener (' pointerdown' , handlePointerDown )
198260 document .removeEventListener (' keydown' , handleKeyDown )
199261 }
200262})
@@ -203,14 +265,40 @@ watch(content, () => {
203265 // Only auto-scroll if enabled (user hasn't scrolled away from bottom)
204266 if (! autoScrollEnabled .value )
205267 return
268+ // Sometimes the rendered height isn't stable immediately (async rendering, images, third-party
269+ // renderers like mermaid, or syntax highlighting). Retry a few times while yielding to the
270+ // browser (nextTick + requestAnimationFrame) so we reliably end up at the bottom.
271+ async function scrollToBottomWithRetries(maxAttempts = 6 , delay = 20 ) {
272+ if (! messagesContainer .value )
273+ return
274+
275+ for (let i = 0 ; i < maxAttempts ; i ++ ) {
276+ // Wait for Vue DOM updates
277+
278+ await nextTick ()
279+ // Wait for a frame so layout/paint settle
206280
207- nextTick (() => {
208- if (messagesContainer .value ) {
209- // Use scrollTo with behavior 'auto' to force immediate jump to bottom and avoid issues with
210- // CSS smooth scrolling that can make programmatic jumps behave inconsistently.
211- messagesContainer .value .scrollTo ({ top: messagesContainer .value .scrollHeight , behavior: ' auto' })
281+ await new Promise (resolve => requestAnimationFrame (() => resolve (undefined )))
282+
283+ if (! messagesContainer .value )
284+ return
285+
286+ const el = messagesContainer .value
287+ const prevScrollHeight = el .scrollHeight
288+ // Force immediate jump to bottom
289+ el .scrollTo ({ top: el .scrollHeight , behavior: ' auto' })
290+
291+ // If height didn't change much or we're at bottom, stop retrying
292+ if (Math .abs (el .scrollHeight - prevScrollHeight ) < 2 || isAtBottom (el , 2 ))
293+ return
294+
295+ // Small delay before next attempt
296+
297+ await new Promise (resolve => setTimeout (resolve , delay ))
212298 }
213- })
299+ }
300+
301+ scrollToBottomWithRetries ()
214302})
215303 </script >
216304
0 commit comments