fix: Dramatically better auto scroll

This commit is contained in:
Zoe
2026-08-04 01:04:33 -05:00
parent a836082de8
commit c9e48687ef
2 changed files with 191 additions and 54 deletions
+185 -44
View File
@@ -3,80 +3,221 @@ import { ref, watch, onUnmounted, type Ref } from 'vue';
export function useAutoScroll(elementRef: Ref<HTMLElement | null>, 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;
},
};
}
}
+6 -10
View File
@@ -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);
<div class="sticky bottom-0 z-10 bg-[var(--bg-surface)] pb-4 w-full rounded-t-2xl">
<ChatInput v-model="inputValue" class="[view-transition-name:chat-prompt] duration-150 ease-in-out"
:loading="activeGeneration !== null" :allow-manual-role="true" :agent="agent"
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage" @add-message="handleAddMessage" @cancel="handleCancel"
@resize="handleResize" />
:providers="providers?.filter(p => p.enabled)" @submit="submitMessage"
@add-message="handleAddMessage" @cancel="handleCancel" @resize="handleResize" />
</div>
</div>
</div>