diff options
| author | Adam <[email protected]> | 2026-03-09 07:36:39 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-09 07:36:39 -0500 |
| commit | c71d1bde5e8dcc8be49c15697ad2e5d0f2607e5e (patch) | |
| tree | a30482cedb38dc24cad70e24ad717817065620d6 /packages/ui/src/hooks | |
| parent | f27ef595f65aa719be3f8d08665d683e95083ed3 (diff) | |
| download | opencode-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.tsx | 245 | ||||
| -rw-r--r-- | packages/ui/src/hooks/index.ts | 1 | ||||
| -rw-r--r-- | packages/ui/src/hooks/use-reduced-motion.ts | 10 |
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() -}) |
