summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/hooks
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-09 07:36:39 -0500
committerGitHub <[email protected]>2026-03-09 07:36:39 -0500
commitc71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e (patch)
treea30482cedb38dc24cad70e24ad717817065620d6 /packages/ui/src/hooks
parentf27ef595f65aa719be3f8d08665d683e95083ed3 (diff)
downloadopencode-c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e.tar.gz
opencode-c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e.zip
revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745)
Diffstat (limited to 'packages/ui/src/hooks')
-rw-r--r--packages/ui/src/hooks/create-auto-scroll.tsx245
-rw-r--r--packages/ui/src/hooks/index.ts1
-rw-r--r--packages/ui/src/hooks/use-reduced-motion.ts10
3 files changed, 58 insertions, 198 deletions
diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx
index d36102590..3dc520c62 100644
--- a/packages/ui/src/hooks/create-auto-scroll.tsx
+++ b/packages/ui/src/hooks/create-auto-scroll.tsx
@@ -1,8 +1,6 @@
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
-import { animate, type AnimationPlaybackControls } from "motion"
-import { FAST_SPRING } from "../components/motion"
export interface AutoScrollOptions {
working: () => boolean
@@ -11,28 +9,13 @@ export interface AutoScrollOptions {
bottomThreshold?: number
}
-const SETTLE_MS = 500
-const AUTO_SCROLL_GRACE_MS = 120
-const AUTO_SCROLL_EPSILON = 0.5
-const MANUAL_ANCHOR_MS = 3000
-const MANUAL_ANCHOR_QUIET_FRAMES = 24
-
export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
+ let autoTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
- let programmaticUntil = 0
- let scrollAnim: AnimationPlaybackControls | undefined
- let hold:
- | {
- el: HTMLElement
- top: number
- until: number
- quiet: number
- frame: number | undefined
- }
- | undefined
+ let auto: { top: number; time: number } | undefined
const threshold = () => options.bottomThreshold ?? 10
@@ -44,160 +27,77 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
- // With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
- return Math.abs(el.scrollTop)
+ return el.scrollHeight - el.clientHeight - el.scrollTop
}
const canScroll = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight > 1
}
- const markProgrammatic = () => {
- programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
- }
+ // Browsers can dispatch scroll events asynchronously. If new content arrives
+ // between us calling `scrollTo()` and the subsequent `scroll` event firing,
+ // the handler can see a non-zero `distanceFromBottom` and incorrectly assume
+ // the user scrolled.
+ const markAuto = (el: HTMLElement) => {
+ auto = {
+ top: Math.max(0, el.scrollHeight - el.clientHeight),
+ time: Date.now(),
+ }
- const clearHold = () => {
- const next = hold
- if (!next) return
- if (next.frame !== undefined) cancelAnimationFrame(next.frame)
- hold = undefined
+ if (autoTimer) clearTimeout(autoTimer)
+ autoTimer = setTimeout(() => {
+ auto = undefined
+ autoTimer = undefined
+ }, 1500)
}
- const tickHold = () => {
- const next = hold
- const el = scroll
- if (!next || !el) return false
- if (Date.now() > next.until) {
- clearHold()
- return false
- }
- if (!next.el.isConnected) {
- clearHold()
- return false
- }
+ const isAuto = (el: HTMLElement) => {
+ const a = auto
+ if (!a) return false
- const current = next.el.getBoundingClientRect().top
- if (!Number.isFinite(current)) {
- clearHold()
+ if (Date.now() - a.time > 1500) {
+ auto = undefined
return false
}
- const delta = current - next.top
- if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) {
- next.quiet += 1
- if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) {
- clearHold()
- return false
- }
- return true
- }
-
- next.quiet = 0
- if (!store.userScrolled) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
- }
- el.scrollTop += delta
- markProgrammatic()
- return true
- }
-
- const scheduleHold = () => {
- const next = hold
- if (!next) return
- if (next.frame !== undefined) return
-
- next.frame = requestAnimationFrame(() => {
- const value = hold
- if (!value) return
- value.frame = undefined
- if (!tickHold()) return
- scheduleHold()
- })
+ return Math.abs(el.scrollTop - a.top) < 2
}
- const preserve = (target: HTMLElement) => {
+ const scrollToBottomNow = (behavior: ScrollBehavior) => {
const el = scroll
if (!el) return
-
- if (!store.userScrolled) {
- setStore("userScrolled", true)
- options.onUserInteracted?.()
+ markAuto(el)
+ if (behavior === "smooth") {
+ el.scrollTo({ top: el.scrollHeight, behavior })
+ return
}
- const top = target.getBoundingClientRect().top
- if (!Number.isFinite(top)) return
-
- clearHold()
- hold = {
- el: target,
- top,
- until: Date.now() + MANUAL_ANCHOR_MS,
- quiet: 0,
- frame: undefined,
- }
- scheduleHold()
+ // `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
+ el.scrollTop = el.scrollHeight
}
const scrollToBottom = (force: boolean) => {
if (!force && !active()) return
- clearHold()
-
if (force && store.userScrolled) setStore("userScrolled", false)
const el = scroll
if (!el) return
- if (scrollAnim) cancelSmooth()
if (!force && store.userScrolled) return
- // With column-reverse, scrollTop=0 is at the bottom
- if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
- markProgrammatic()
+ const distance = distanceFromBottom(el)
+ if (distance < 2) {
+ markAuto(el)
return
}
- el.scrollTop = 0
- markProgrammatic()
- }
-
- const cancelSmooth = () => {
- if (scrollAnim) {
- scrollAnim.stop()
- scrollAnim = undefined
- }
+ // For auto-following content we prefer immediate updates to avoid
+ // visible "catch up" animations while content is still settling.
+ scrollToBottomNow("auto")
}
- const smoothScrollToBottom = () => {
- const el = scroll
- if (!el) return
-
- cancelSmooth()
- if (store.userScrolled) setStore("userScrolled", false)
-
- // With column-reverse, scrollTop=0 is at the bottom
- if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
- markProgrammatic()
- return
- }
-
- scrollAnim = animate(el.scrollTop, 0, {
- ...FAST_SPRING,
- onUpdate: (v) => {
- markProgrammatic()
- el.scrollTop = v
- },
- onComplete: () => {
- scrollAnim = undefined
- markProgrammatic()
- },
- })
- }
-
- const stop = (input?: { hold?: boolean }) => {
- if (input?.hold !== false) clearHold()
-
+ const stop = () => {
const el = scroll
if (!el) return
if (!canScroll(el)) {
@@ -206,25 +106,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
if (store.userScrolled) return
- markProgrammatic()
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const handleWheel = (e: WheelEvent) => {
- if (e.deltaY !== 0) clearHold()
-
- if (e.deltaY > 0) {
- const el = scroll
- if (!el) return
- if (distanceFromBottom(el) >= threshold()) return
- if (store.userScrolled) setStore("userScrolled", false)
- markProgrammatic()
- return
- }
-
if (e.deltaY >= 0) return
- cancelSmooth()
+ // If the user is scrolling within a nested scrollable region (tool output,
+ // code block, etc), don't treat it as leaving the "follow bottom" mode.
+ // Those regions opt in via `data-scrollable`.
const el = scroll
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
@@ -236,27 +126,23 @@ export function createAutoScroll(options: AutoScrollOptions) {
const el = scroll
if (!el) return
- if (hold) {
- if (Date.now() < programmaticUntil) return
- clearHold()
- }
-
if (!canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
- markProgrammatic()
return
}
if (distanceFromBottom(el) < threshold()) {
- if (Date.now() < programmaticUntil) return
if (store.userScrolled) setStore("userScrolled", false)
- markProgrammatic()
return
}
- if (!store.userScrolled && Date.now() < programmaticUntil) return
+ // Ignore scroll events triggered by our own scrollToBottom calls.
+ if (!store.userScrolled && isAuto(el)) {
+ scrollToBottom(false)
+ return
+ }
- stop({ hold: false })
+ stop()
}
const handleInteraction = () => {
@@ -268,11 +154,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
const updateOverflowAnchor = (el: HTMLElement) => {
- if (hold) {
- el.style.overflowAnchor = "none"
- return
- }
-
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
@@ -292,17 +173,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
() => store.contentRef,
() => {
const el = scroll
- if (hold) {
- scheduleHold()
- return
- }
if (el && !canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
- markProgrammatic()
return
}
if (!active()) return
if (store.userScrolled) return
+ // ResizeObserver fires after layout, before paint.
+ // Keep the bottom locked in the same frame to avoid visible
+ // "jump up then catch up" artifacts while streaming content.
scrollToBottom(false)
},
)
@@ -321,11 +200,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
settling = true
settleTimer = setTimeout(() => {
settling = false
- }, SETTLE_MS)
+ }, 300)
}),
)
createEffect(() => {
+ // Track `userScrolled` even before `scrollRef` is attached, so we can
+ // update overflow anchoring once the element exists.
store.userScrolled
const el = scroll
if (!el) return
@@ -334,8 +215,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
- clearHold()
- cancelSmooth()
+ if (autoTimer) clearTimeout(autoTimer)
if (cleanup) cleanup()
})
@@ -348,12 +228,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
scroll = el
- if (!el) {
- clearHold()
- return
- }
+ if (!el) return
- markProgrammatic()
updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true })
@@ -364,18 +240,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
- preserve,
pause: stop,
- forceScrollToBottom: () => scrollToBottom(true),
- smoothScrollToBottom,
- snapToBottom: () => {
- const el = scroll
- if (!el) return
+ resume: () => {
if (store.userScrolled) setStore("userScrolled", false)
- // With column-reverse, scrollTop=0 is at the bottom
- el.scrollTop = 0
- markProgrammatic()
+ scrollToBottom(true)
},
+ scrollToBottom: () => scrollToBottom(false),
+ forceScrollToBottom: () => scrollToBottom(true),
userScrolled: () => store.userScrolled,
}
}
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts
index 0fcf6f086..1c90a2e49 100644
--- a/packages/ui/src/hooks/index.ts
+++ b/packages/ui/src/hooks/index.ts
@@ -1,3 +1,2 @@
export * from "./use-filtered-list"
export * from "./create-auto-scroll"
-export * from "./use-reduced-motion"
diff --git a/packages/ui/src/hooks/use-reduced-motion.ts b/packages/ui/src/hooks/use-reduced-motion.ts
deleted file mode 100644
index 0038760ec..000000000
--- a/packages/ui/src/hooks/use-reduced-motion.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { isHydrated } from "@solid-primitives/lifecycle"
-import { createMediaQuery } from "@solid-primitives/media"
-import { createHydratableSingletonRoot } from "@solid-primitives/rootless"
-
-const query = "(prefers-reduced-motion: reduce)"
-
-export const useReducedMotion = createHydratableSingletonRoot(() => {
- const value = createMediaQuery(query)
- return () => !isHydrated() || value()
-})