Skip to content

Commit 4df7e78

Browse files
committed
fix: chat scrollbar on sidebar collapse/open
1 parent 1266a66 commit 4df7e78

File tree

1 file changed

+31
-55
lines changed

1 file changed

+31
-55
lines changed

apps/sim/app/workspace/[workspaceId]/home/hooks/use-auto-scroll.ts

Lines changed: 31 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,15 @@ const BOTTOM_THRESHOLD = 30
66
* Manages sticky auto-scroll for a streaming chat container.
77
*
88
* Stays pinned to the bottom while content streams in. Detaches when the user
9-
* explicitly scrolls up (wheel, touch, or scrollbar drag). Re-attaches when
10-
* the scroll position returns to within {@link BOTTOM_THRESHOLD} of the bottom.
9+
* scrolls beyond {@link BOTTOM_THRESHOLD} from the bottom. Re-attaches when
10+
* the scroll position returns within the threshold. Preserves bottom position
11+
* across container resizes (e.g. sidebar collapse).
1112
*/
1213
export function useAutoScroll(isStreaming: boolean) {
1314
const containerRef = useRef<HTMLDivElement>(null)
14-
const stickyRef = useRef(true)
15-
const prevScrollTopRef = useRef(0)
16-
const prevScrollHeightRef = useRef(0)
17-
const touchStartYRef = useRef(0)
15+
const atBottomRef = useRef(true)
1816
const rafIdRef = useRef(0)
17+
const teardownRef = useRef<(() => void) | null>(null)
1918

2019
const scrollToBottom = useCallback(() => {
2120
const el = containerRef.current
@@ -24,80 +23,57 @@ export function useAutoScroll(isStreaming: boolean) {
2423
}, [])
2524

2625
const callbackRef = useCallback((el: HTMLDivElement | null) => {
26+
teardownRef.current?.()
27+
teardownRef.current = null
2728
containerRef.current = el
28-
if (el) el.scrollTop = el.scrollHeight
29-
}, [])
30-
31-
useEffect(() => {
32-
if (!isStreaming) return
33-
const el = containerRef.current
3429
if (!el) return
3530

36-
stickyRef.current = true
37-
prevScrollTopRef.current = el.scrollTop
38-
prevScrollHeightRef.current = el.scrollHeight
39-
scrollToBottom()
31+
el.scrollTop = el.scrollHeight
32+
atBottomRef.current = true
4033

41-
const detach = () => {
42-
stickyRef.current = false
34+
const onScroll = () => {
35+
const { scrollTop, scrollHeight, clientHeight } = el
36+
atBottomRef.current = scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD
4337
}
4438

45-
const onWheel = (e: WheelEvent) => {
46-
if (e.deltaY < 0) detach()
47-
}
39+
const ro = new ResizeObserver(() => {
40+
if (atBottomRef.current) el.scrollTop = el.scrollHeight
41+
})
4842

49-
const onTouchStart = (e: TouchEvent) => {
50-
touchStartYRef.current = e.touches[0].clientY
51-
}
43+
el.addEventListener('scroll', onScroll, { passive: true })
44+
ro.observe(el)
5245

53-
const onTouchMove = (e: TouchEvent) => {
54-
if (e.touches[0].clientY > touchStartYRef.current) detach()
46+
teardownRef.current = () => {
47+
el.removeEventListener('scroll', onScroll)
48+
ro.disconnect()
5549
}
50+
}, [])
5651

57-
const onScroll = () => {
58-
const { scrollTop, scrollHeight, clientHeight } = el
59-
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
60-
61-
if (distanceFromBottom <= BOTTOM_THRESHOLD) {
62-
stickyRef.current = true
63-
} else if (
64-
scrollTop < prevScrollTopRef.current &&
65-
scrollHeight <= prevScrollHeightRef.current
66-
) {
67-
stickyRef.current = false
68-
}
69-
70-
prevScrollTopRef.current = scrollTop
71-
prevScrollHeightRef.current = scrollHeight
72-
}
52+
useEffect(() => {
53+
if (!isStreaming) return
54+
const el = containerRef.current
55+
if (!el) return
56+
57+
atBottomRef.current = true
58+
scrollToBottom()
7359

7460
const guardedScroll = () => {
75-
if (stickyRef.current) scrollToBottom()
61+
if (atBottomRef.current) scrollToBottom()
7662
}
7763

7864
const onMutation = () => {
79-
prevScrollHeightRef.current = el.scrollHeight
80-
if (!stickyRef.current) return
65+
if (!atBottomRef.current) return
8166
cancelAnimationFrame(rafIdRef.current)
8267
rafIdRef.current = requestAnimationFrame(guardedScroll)
8368
}
8469

85-
el.addEventListener('wheel', onWheel, { passive: true })
86-
el.addEventListener('touchstart', onTouchStart, { passive: true })
87-
el.addEventListener('touchmove', onTouchMove, { passive: true })
88-
el.addEventListener('scroll', onScroll, { passive: true })
89-
9070
const observer = new MutationObserver(onMutation)
9171
observer.observe(el, { childList: true, subtree: true, characterData: true })
9272

9373
return () => {
94-
el.removeEventListener('wheel', onWheel)
95-
el.removeEventListener('touchstart', onTouchStart)
96-
el.removeEventListener('touchmove', onTouchMove)
97-
el.removeEventListener('scroll', onScroll)
9874
observer.disconnect()
9975
cancelAnimationFrame(rafIdRef.current)
100-
if (stickyRef.current) scrollToBottom()
76+
if (atBottomRef.current) scrollToBottom()
10177
}
10278
}, [isStreaming, scrollToBottom])
10379

0 commit comments

Comments
 (0)