From c9e48687ef1309a65d5249cd43914a75db93b575 Mon Sep 17 00:00:00 2001 From: Zoe Date: Tue, 4 Aug 2026 01:04:33 -0500 Subject: [PATCH] fix: Dramatically better auto scroll --- app/composables/useAutoScroll.ts | 229 ++++++++++++++++++----- app/pages/agent/[id]/topic/[topicId].vue | 16 +- 2 files changed, 191 insertions(+), 54 deletions(-) diff --git a/app/composables/useAutoScroll.ts b/app/composables/useAutoScroll.ts index bc23e4f..04cc4f9 100644 --- a/app/composables/useAutoScroll.ts +++ b/app/composables/useAutoScroll.ts @@ -3,80 +3,221 @@ import { ref, watch, onUnmounted, type Ref } from 'vue'; export function useAutoScroll(elementRef: Ref, options: { threshold?: number; } = {}) { - const { threshold = 80 } = options; + const { threshold = 30 } = options; - const isUserScrollingUp = ref(false); + /** User intentionally left the bottom — stop following new content */ + const unhooked = ref(false); + /** Back-compat alias for UI that keys off "user scrolled away" */ + const isUserScrollingUp = unhooked; const shouldAutoScroll = ref(true); - const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + let isProgrammatic = false; + let lastScrollTop = 0; + let mutationRaf: number | null = null; + let anchorRaf: number | null = null; + let observer: MutationObserver | null = null; + let resizeObserver: ResizeObserver | null = null; + let lastWidth = 0; + let anchor: { element: Element; offsetFromTop: number } | null = null; + + const isAtBottom = (el: HTMLElement) => + el.scrollHeight - el.scrollTop - el.clientHeight <= threshold; + + const setUnhooked = (value: boolean) => { + unhooked.value = value; + shouldAutoScroll.value = !value; + }; + + const scrollToBottom = (behavior: ScrollBehavior = 'instant') => { const el = elementRef.value; if (!el) return; + + isProgrammatic = true; el.scrollTo({ top: el.scrollHeight, behavior, }); - shouldAutoScroll.value = true; + + requestAnimationFrame(() => { + isProgrammatic = false; + if (elementRef.value && isAtBottom(elementRef.value)) { + setUnhooked(false); + lastScrollTop = elementRef.value.scrollTop; + } + }); + }; + + const captureAnchor = (el: HTMLElement) => { + const rect = el.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const offsets = [4, 24, 48, 80, 120]; + + for (const y of offsets) { + const target = document.elementFromPoint(x, rect.top + y); + if (!target || target === el || !el.contains(target)) continue; + anchor = { + element: target, + offsetFromTop: target.getBoundingClientRect().top - rect.top, + }; + return; + } + + anchor = null; + }; + + const scheduleAnchorCapture = () => { + if (anchorRaf !== null) return; + anchorRaf = requestAnimationFrame(() => { + anchorRaf = null; + const el = elementRef.value; + if (el) captureAnchor(el); + }); + }; + + const restoreAnchor = (el: HTMLElement) => { + if (!anchor || !el.contains(anchor.element)) return; + + const top = el.getBoundingClientRect().top; + const delta = + anchor.element.getBoundingClientRect().top - top - anchor.offsetFromTop; + if (Math.abs(delta) < 0.5) return; + + isProgrammatic = true; + el.scrollTop += delta; + requestAnimationFrame(() => { + isProgrammatic = false; + }); + }; + + const handleWheel = (event: WheelEvent) => { + isProgrammatic = false; + // Intent to leave bottom — unhook before content mutations can re-stick + if (event.deltaY < 0) { + setUnhooked(true); + } }; const handleScroll = () => { const el = elementRef.value; if (!el) return; - const { scrollTop, scrollHeight, clientHeight } = el; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const scrollingUp = el.scrollTop < lastScrollTop; + lastScrollTop = el.scrollTop; - if (distanceFromBottom <= threshold) { - if (isUserScrollingUp.value) { - isUserScrollingUp.value = false; - shouldAutoScroll.value = true; - } - } else { - isUserScrollingUp.value = true; - shouldAutoScroll.value = false; + scheduleAnchorCapture(); + + if (isProgrammatic) { + if (isAtBottom(el)) setUnhooked(false); + return; + } + + // Only re-hook when actually inside the bottom threshold. + // Mid-page scroll (up or down) must never re-enable stick. + if (isAtBottom(el)) { + setUnhooked(false); + return; + } + + if (scrollingUp) { + setUnhooked(true); } }; - let observer: MutationObserver | null = null; - let timeout: NodeJS.Timeout | null = null; + const followIfHooked = () => { + if (unhooked.value) return; + scrollToBottom('instant'); + }; - watch(elementRef, (newEl, oldEl) => { - if (oldEl) { - oldEl.removeEventListener('scroll', handleScroll); - observer?.disconnect(); - if (timeout) { - clearTimeout(timeout); - timeout = null; - } - } + const handleMutation = () => { + if (mutationRaf !== null) return; + mutationRaf = requestAnimationFrame(() => { + mutationRaf = null; + followIfHooked(); + }); + }; - if (newEl) { - newEl.addEventListener('scroll', handleScroll, { passive: true }); + const attach = (el: HTMLElement) => { + el.style.overflowAnchor = 'none'; + lastScrollTop = el.scrollTop; + lastWidth = el.clientWidth; + setUnhooked(false); - observer = new MutationObserver(() => { - // Only auto-scroll if user hasn't scrolled up and is near bottom - if (!isUserScrollingUp.value && shouldAutoScroll.value) { + el.addEventListener('wheel', handleWheel, { passive: true }); + el.addEventListener('scroll', handleScroll, { passive: true }); + + observer = new MutationObserver(handleMutation); + observer.observe(el, { + childList: true, + subtree: true, + characterData: true, + }); + + resizeObserver = new ResizeObserver(() => { + const current = elementRef.value; + if (!current) return; + + const width = current.clientWidth; + if (width === lastWidth) { + // Height-only growth (streaming layout): stay stuck if hooked + if (!unhooked.value) { scrollToBottom('instant'); } - }); + return; + } - observer.observe(newEl, { - childList: true, - subtree: true, - characterData: true, - }); + lastWidth = width; + + if (!unhooked.value) { + scrollToBottom('instant'); + return; + } + + restoreAnchor(current); + }); + resizeObserver.observe(el); + + // Content already taller than the viewport on mount — catch up once + requestAnimationFrame(() => { + if (!unhooked.value) scrollToBottom('instant'); + }); + }; + + const detach = (el: HTMLElement | null) => { + if (el) { + el.removeEventListener('wheel', handleWheel); + el.removeEventListener('scroll', handleScroll); } - }); + observer?.disconnect(); + observer = null; + resizeObserver?.disconnect(); + resizeObserver = null; + if (mutationRaf !== null) { + cancelAnimationFrame(mutationRaf); + mutationRaf = null; + } + if (anchorRaf !== null) { + cancelAnimationFrame(anchorRaf); + anchorRaf = null; + } + anchor = null; + }; + + watch(elementRef, (newEl, oldEl) => { + detach(oldEl ?? null); + if (newEl) attach(newEl); + }, { immediate: true }); onUnmounted(() => { - elementRef.value?.removeEventListener('scroll', handleScroll); - observer?.disconnect(); - if (timeout) { - clearTimeout(timeout); - } + detach(elementRef.value); }); return { scrollToBottom, - isUserScrollingUp, // expose for UI feedback (optional) + isUserScrollingUp, + shouldAutoScroll, + isAtBottom: () => { + const el = elementRef.value; + return el ? isAtBottom(el) : true; + }, }; -} \ No newline at end of file +} diff --git a/app/pages/agent/[id]/topic/[topicId].vue b/app/pages/agent/[id]/topic/[topicId].vue index ef4201c..d39d27e 100644 --- a/app/pages/agent/[id]/topic/[topicId].vue +++ b/app/pages/agent/[id]/topic/[topicId].vue @@ -147,19 +147,17 @@ const handleDelete = async (rootMessage: Message) => { } } -const handleResize = () => { - const el = chatPaneWrapper.value; - if (!el) return; +const { scrollToBottom, isAtBottom } = useAutoScroll(chatPaneWrapper); - const atBottom = (el.scrollHeight - el.scrollTop - el.clientHeight) <= 80; - if (!atBottom) return; +const handleResize = () => { + if (!isAtBottom()) return; nextTick().then(() => { requestAnimationFrame(() => { scrollToBottom('instant'); }); }); -} +}; const activeGeneration = computed(() => { if (topic.value === null) return null; @@ -167,8 +165,6 @@ const activeGeneration = computed(() => { return generations?.find((generation) => generation?.status === 'pending') ?? null; }); -const { scrollToBottom } = useAutoScroll(chatPaneWrapper); - addShortcut(['ctrl', 'alt', 'n'], (event) => { event.preventDefault(); event.stopPropagation(); @@ -328,8 +324,8 @@ console.log("full page render took", Date.now() - rootStart);
+ :providers="providers?.filter(p => p.enabled)" @submit="submitMessage" + @add-message="handleAddMessage" @cancel="handleCancel" @resize="handleResize" />