summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/hooks
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-22 11:27:23 -0600
committerAdam <[email protected]>2025-12-22 11:27:27 -0600
commit291b65977c0de428f86886acd05f79c46051be90 (patch)
treec71809481733577a59e90d2ce049e26d33388a28 /packages/ui/src/hooks
parent90f232d7f15a8bf09ebeed24297ceb5f4ea4dbad (diff)
downloadopencode-291b65977c0de428f86886acd05f79c46051be90.tar.gz
opencode-291b65977c0de428f86886acd05f79c46051be90.zip
chore(desktop): auto scroll utility
Diffstat (limited to 'packages/ui/src/hooks')
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx135
-rw-r--r--packages/ui/src/hooks/index.ts1
2 files changed, 136 insertions, 0 deletions
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
new file mode 100644
index 000000000..d201fe36b
--- /dev/null
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -0,0 +1,135 @@
+import { batch, createEffect } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
+
+export interface AutoScrollOptions {
+ working: () => boolean
+ onUserInteracted?: () => void
+}
+
+export function createAutoScroll(options: AutoScrollOptions) {
+ let scrollRef: HTMLElement | undefined
+ const [store, setStore] = createStore({
+ contentRef: undefined as HTMLElement | undefined,
+ lastScrollTop: 0,
+ lastScrollHeight: 0,
+ lastContentWidth: 0,
+ autoScrolled: false,
+ userScrolled: false,
+ reflowing: false,
+ })
+
+ function scrollToBottom() {
+ if (!scrollRef || store.userScrolled || !options.working()) return
+ setStore("autoScrolled", true)
+ requestAnimationFrame(() => {
+ scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
+ requestAnimationFrame(() => {
+ batch(() => {
+ setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
+ setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
+ setStore("autoScrolled", false)
+ })
+ })
+ })
+ }
+
+ function handleScroll() {
+ if (!scrollRef || store.autoScrolled) return
+
+ const scrollTop = scrollRef.scrollTop
+ const scrollHeight = scrollRef.scrollHeight
+
+ if (store.reflowing) {
+ batch(() => {
+ setStore("lastScrollTop", scrollTop)
+ setStore("lastScrollHeight", scrollHeight)
+ })
+ return
+ }
+
+ const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
+ const scrollTopDelta = scrollTop - store.lastScrollTop
+
+ // Handle reflow-caused scroll position changes
+ if (scrollHeightChanged && scrollTopDelta < 0) {
+ const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
+ const expectedScrollTop = store.lastScrollTop * heightRatio
+ if (Math.abs(scrollTop - expectedScrollTop) < 100) {
+ batch(() => {
+ setStore("lastScrollTop", scrollTop)
+ setStore("lastScrollHeight", scrollHeight)
+ })
+ return
+ }
+ }
+
+ // Handle reset to top while working
+ const reset = scrollTop <= 0 && store.lastScrollTop > 0 && options.working() && !store.userScrolled
+ if (reset) {
+ batch(() => {
+ setStore("lastScrollTop", scrollTop)
+ setStore("lastScrollHeight", scrollHeight)
+ })
+ requestAnimationFrame(scrollToBottom)
+ return
+ }
+
+ // Detect intentional scroll up
+ const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
+ if (scrolledUp && options.working()) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
+ }
+
+ batch(() => {
+ setStore("lastScrollTop", scrollTop)
+ setStore("lastScrollHeight", scrollHeight)
+ })
+ }
+
+ function handleInteraction() {
+ if (options.working()) {
+ setStore("userScrolled", true)
+ options.onUserInteracted?.()
+ }
+ }
+
+ // Reset userScrolled when work completes
+ createEffect(() => {
+ if (!options.working()) setStore("userScrolled", false)
+ })
+
+ // Handle content resize
+ createResizeObserver(
+ () => store.contentRef,
+ ({ width }) => {
+ const widthChanged = Math.abs(width - store.lastContentWidth) > 5
+ if (widthChanged && store.lastContentWidth > 0) {
+ setStore("reflowing", true)
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ setStore("reflowing", false)
+ if (options.working() && !store.userScrolled) {
+ scrollToBottom()
+ }
+ })
+ })
+ } else if (!store.reflowing) {
+ scrollToBottom()
+ }
+ setStore("lastContentWidth", width)
+ },
+ )
+
+ return {
+ scrollRef: (el: HTMLElement | undefined) => {
+ scrollRef = el
+ },
+ contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
+ handleScroll,
+ handleInteraction,
+ scrollToBottom,
+ userScrolled: () => store.userScrolled,
+ }
+}
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts
index 7eef78091..1c90a2e49 100644
--- a/packages/ui/src/hooks/index.ts
+++ b/packages/ui/src/hooks/index.ts
@@ -1 +1,2 @@
export * from "./use-filtered-list"
+export * from "./create-auto-scroll"