summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-04 04:24:32 -0600
committerAdam <[email protected]>2026-01-04 04:24:37 -0600
commit7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5 (patch)
tree3d61246fb5a99aee7f6de0682454d8802f907cb6 /packages/ui/src
parent4486174e4319b9523d134fda863677f35e740105 (diff)
downloadopencode-7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5.tar.gz
opencode-7ce0520f8da74a26f92fd7516d3f8c57dc3ac4c5.zip
fix(app): auto-scroll behaviors
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx253
1 files changed, 61 insertions, 192 deletions
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
index 1fb2e0c81..6dacc2795 100644
--- a/packages/ui/src/hooks/create-auto-scroll.tsx
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -1,4 +1,4 @@
-import { createEffect, onCleanup } from "solid-js"
+import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -8,240 +8,109 @@ export interface AutoScrollOptions {
}
export function createAutoScroll(options: AutoScrollOptions) {
- let scrollRef: HTMLElement | undefined
+ let scroll: HTMLElement | undefined
+ let settling = false
+ let settleTimer: ReturnType<typeof setTimeout> | undefined
+
const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
userScrolled: false,
})
- let lastScrollTop = 0
- let isAutoScrolling = false
- let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined
- let isMouseDown = false
- let cleanupListeners: (() => void) | undefined
- let scheduledScroll = false
- let scheduledForce = false
-
- function distanceFromBottom() {
- if (!scrollRef) return 0
- return scrollRef.scrollHeight - scrollRef.clientHeight - scrollRef.scrollTop
- }
+ const active = () => options.working() || settling
- function startAutoScroll() {
- isAutoScrolling = true
- if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
- autoScrollTimeout = setTimeout(() => {
- isAutoScrolling = false
- }, 1000)
+ const distanceFromBottom = () => {
+ const el = scroll
+ if (!el) return 0
+ return el.scrollHeight - el.clientHeight - el.scrollTop
}
- function scrollToBottomNow() {
- if (!scrollRef || store.userScrolled || !options.working()) return
-
- const distance = distanceFromBottom()
- if (distance < 2) return
-
- const behavior = distance > 96 ? "auto" : "smooth"
- startAutoScroll()
- scrollRef.scrollTo({
- top: scrollRef.scrollHeight,
- behavior,
- })
+ const scrollToBottomNow = (behavior: ScrollBehavior) => {
+ const el = scroll
+ if (!el) return
+ el.scrollTo({ top: el.scrollHeight, behavior })
}
- function forceScrollToBottomNow() {
- if (!scrollRef) return
+ const scrollToBottom = (force: boolean) => {
+ if (!force && !active()) return
+ if (!scroll) return
- if (store.userScrolled) setStore("userScrolled", false)
+ if (!force && store.userScrolled) return
+ if (force && store.userScrolled) setStore("userScrolled", false)
const distance = distanceFromBottom()
if (distance < 2) return
- startAutoScroll()
- scrollRef.scrollTo({
- top: scrollRef.scrollHeight,
- behavior: "auto",
- })
+ const behavior: ScrollBehavior = force || distance > 96 ? "auto" : "smooth"
+ scrollToBottomNow(behavior)
}
- function scheduleScrollToBottom(force = false) {
- if (typeof requestAnimationFrame === "undefined") {
- if (force) {
- forceScrollToBottomNow()
- return
- }
- scrollToBottomNow()
- return
- }
-
- if (force) scheduledForce = true
- if (scheduledScroll) return
-
- scheduledScroll = true
- requestAnimationFrame(() => {
- scheduledScroll = false
-
- const shouldForce = scheduledForce
- scheduledForce = false
-
- if (shouldForce) {
- forceScrollToBottomNow()
- return
- }
+ const handleScroll = () => {
+ if (!options.working()) return
+ if (!scroll) return
- scrollToBottomNow()
- })
- }
-
- function scrollToBottom() {
- scheduleScrollToBottom(false)
- }
-
- function forceScrollToBottom() {
- scheduleScrollToBottom(true)
- }
-
- function handleScroll() {
- if (!scrollRef) return
-
- const { scrollTop, scrollHeight, clientHeight } = scrollRef
- const atBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10
-
- if (isAutoScrolling) {
- if (atBottom) {
- isAutoScrolling = false
- if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
- }
- lastScrollTop = scrollTop
- return
- }
-
- if (atBottom) {
- if (store.userScrolled) {
- setStore("userScrolled", false)
- }
- lastScrollTop = scrollTop
+ if (distanceFromBottom() < 10) {
+ if (store.userScrolled) setStore("userScrolled", false)
return
}
- const delta = scrollTop - lastScrollTop
- if (delta < 0) {
- if (isMouseDown && !store.userScrolled && options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- }
-
- lastScrollTop = scrollTop
- }
+ if (store.userScrolled) return
- function handleInteraction() {
- if (options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
}
- function handleWheel(e: WheelEvent) {
- if (e.deltaY < 0 && !store.userScrolled && options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- }
+ const handleInteraction = () => {
+ if (!options.working()) return
+ if (store.userScrolled) return
- function handleTouchStart() {
- if (!store.userScrolled && options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- }
-
- function handleKeyDown(e: KeyboardEvent) {
- if (["ArrowUp", "PageUp", "Home"].includes(e.key)) {
- if (!store.userScrolled && options.working()) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- }
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
}
- function handleMouseDown() {
- isMouseDown = true
- window.addEventListener("mouseup", handleMouseUp)
- }
+ createResizeObserver(
+ () => store.contentRef,
+ () => {
+ if (!active()) return
+ if (store.userScrolled) return
+ scrollToBottom(false)
+ },
+ )
- function handleMouseUp() {
- isMouseDown = false
- window.removeEventListener("mouseup", handleMouseUp)
- }
+ createEffect(
+ on(options.working, (working) => {
+ settling = false
+ if (settleTimer) clearTimeout(settleTimer)
+ settleTimer = undefined
- // Reset userScrolled when work completes
- createEffect(() => {
- if (!options.working()) {
setStore("userScrolled", false)
- }
- })
-
- // Ensure pinned-to-bottom stays pinned during heavy DOM updates
- createEffect(() => {
- const el = store.contentRef
- if (!el) return
- const observer = new MutationObserver(() => {
- if (store.userScrolled) return
- if (!options.working()) return
- scheduleScrollToBottom(false)
- })
- observer.observe(el, { childList: true, subtree: true, characterData: true })
- onCleanup(() => observer.disconnect())
- })
-
- // Handle content resize
- createResizeObserver(
- () => store.contentRef,
- () => {
- if (options.working() && !store.userScrolled) {
- scrollToBottom()
+ if (working) {
+ scrollToBottom(true)
+ return
}
- },
+
+ settling = true
+ settleTimer = setTimeout(() => {
+ settling = false
+ }, 300)
+ }),
)
onCleanup(() => {
- if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
- if (cleanupListeners) cleanupListeners()
+ if (settleTimer) clearTimeout(settleTimer)
})
return {
scrollRef: (el: HTMLElement | undefined) => {
- if (cleanupListeners) {
- cleanupListeners()
- cleanupListeners = undefined
- }
-
- scrollRef = el
- if (el) {
- lastScrollTop = el.scrollTop
- el.style.overflowAnchor = "none"
-
- el.addEventListener("wheel", handleWheel, { passive: true })
- el.addEventListener("touchstart", handleTouchStart, { passive: true })
- el.addEventListener("keydown", handleKeyDown)
- el.addEventListener("mousedown", handleMouseDown)
-
- cleanupListeners = () => {
- el.removeEventListener("wheel", handleWheel)
- el.removeEventListener("touchstart", handleTouchStart)
- el.removeEventListener("keydown", handleKeyDown)
- el.removeEventListener("mousedown", handleMouseDown)
- window.removeEventListener("mouseup", handleMouseUp)
- }
- }
+ scroll = el
+ if (el) el.style.overflowAnchor = "none"
},
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
- scrollToBottom,
- forceScrollToBottom,
+ scrollToBottom: () => scrollToBottom(false),
+ forceScrollToBottom: () => scrollToBottom(true),
userScrolled: () => store.userScrolled,
}
}