bug fixes, and performance improvements, and drawer

This commit is contained in:
Zoe
2026-09-27 18:44:51 -05:00
parent d48d8163a7
commit 2b9f8d64d7
52 changed files with 4913 additions and 778 deletions
+97 -23
View File
@@ -15,6 +15,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
let lastScrollTop = 0;
let mutationRaf: number | null = null;
let anchorRaf: number | null = null;
let anchorDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
let observer: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
let lastWidth = 0;
@@ -26,15 +27,25 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
const setUnhooked = (value: boolean) => {
unhooked.value = value;
shouldAutoScroll.value = !value;
if (!value) anchor = null;
};
const scrollToBottom = (behavior: ScrollBehavior = 'instant') => {
const el = elementRef.value;
if (!el) return;
if (!el) {
return;
}
const bottom = Math.max(0, el.scrollHeight - el.clientHeight);
if (Math.abs(el.scrollTop - bottom) < 0.5) {
setUnhooked(false);
lastScrollTop = el.scrollTop;
return;
}
isProgrammatic = true;
el.scrollTo({
top: el.scrollHeight,
top: bottom,
behavior,
});
@@ -48,13 +59,19 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
};
const captureAnchor = (el: HTMLElement) => {
if (!unhooked.value) {
anchor = null;
return;
}
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;
if (!target || target === el || !el.contains(target)) {
continue;
}
anchor = {
element: target,
offsetFromTop: target.getBoundingClientRect().top - rect.top,
@@ -65,8 +82,8 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
anchor = null;
};
const scheduleAnchorCapture = () => {
if (anchorRaf !== null) return;
const requestAnchorCapture = () => {
if (!unhooked.value || anchorRaf !== null) return;
anchorRaf = requestAnimationFrame(() => {
anchorRaf = null;
const el = elementRef.value;
@@ -74,13 +91,38 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
});
};
const scheduleAnchorCapture = () => {
const el = elementRef.value;
// Native scrollend is the primary path; debounce is only a fallback.
if (!el || 'onscrollend' in el || !unhooked.value) return;
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
}
anchorDebounceTimeout = setTimeout(() => {
anchorDebounceTimeout = null;
requestAnchorCapture();
}, 150);
};
const handleScrollEnd = () => {
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
anchorDebounceTimeout = null;
}
requestAnchorCapture();
};
const restoreAnchor = (el: HTMLElement) => {
if (!anchor || !el.contains(anchor.element)) return;
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;
if (Math.abs(delta) < 0.5) {
return;
}
isProgrammatic = true;
el.scrollTop += delta;
@@ -99,37 +141,54 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
const handleScroll = () => {
const el = elementRef.value;
if (!el) return;
if (!el) {
return;
}
const scrollingUp = el.scrollTop < lastScrollTop;
lastScrollTop = el.scrollTop;
scheduleAnchorCapture();
if (isProgrammatic) {
if (isAtBottom(el)) setUnhooked(false);
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 the user is scrolling up, they are deliberately navigating away from the bottom.
// Unhook immediately so we never fight the scroll or snap back down.
if (scrollingUp) {
setUnhooked(true);
scheduleAnchorCapture();
return;
}
// Only re-hook when scrolling down into the bottom threshold.
if (isAtBottom(el)) {
setUnhooked(false);
return;
}
if (scrollingUp) {
setUnhooked(true);
}
scheduleAnchorCapture();
};
const followIfHooked = () => {
if (unhooked.value) return;
if (unhooked.value) {
return;
}
scrollToBottom('instant');
};
const handleMutation = () => {
if (mutationRaf !== null) return;
const handleMutation = (records: MutationRecord[]) => {
if (unhooked.value || mutationRaf !== null) {
return;
}
// The isolated sizing mirror changes on every keystroke, but cannot
// change scroll height. Keep observing real composer content (files,
// tool controls, etc.) as well as message content.
if (records.every(record => {
const target = record.target instanceof Element ? record.target : record.target.parentElement;
return target?.closest('[data-chat-measurement]');
})) return;
mutationRaf = requestAnimationFrame(() => {
mutationRaf = null;
followIfHooked();
@@ -144,6 +203,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
el.addEventListener('wheel', handleWheel, { passive: true });
el.addEventListener('scroll', handleScroll, { passive: true });
el.addEventListener('scrollend', handleScrollEnd, { passive: true });
observer = new MutationObserver(handleMutation);
observer.observe(el, {
@@ -154,7 +214,9 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
resizeObserver = new ResizeObserver(() => {
const current = elementRef.value;
if (!current) return;
if (!current) {
return;
}
const width = current.clientWidth;
if (width === lastWidth) {
@@ -172,13 +234,18 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
return;
}
if (!anchor) {
captureAnchor(current);
}
restoreAnchor(current);
});
resizeObserver.observe(el);
// Content already taller than the viewport on mount — catch up once
requestAnimationFrame(() => {
if (!unhooked.value) scrollToBottom('instant');
if (!unhooked.value) {
scrollToBottom('instant');
}
});
};
@@ -186,6 +253,7 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
if (el) {
el.removeEventListener('wheel', handleWheel);
el.removeEventListener('scroll', handleScroll);
el.removeEventListener('scrollend', handleScrollEnd);
}
observer?.disconnect();
observer = null;
@@ -199,12 +267,18 @@ export function useAutoScroll(elementRef: Ref<HTMLElement | null>, options: {
cancelAnimationFrame(anchorRaf);
anchorRaf = null;
}
if (anchorDebounceTimeout !== null) {
clearTimeout(anchorDebounceTimeout);
anchorDebounceTimeout = null;
}
anchor = null;
};
watch(elementRef, (newEl, oldEl) => {
detach(oldEl ?? null);
if (newEl) attach(newEl);
if (newEl) {
attach(newEl);
}
}, { immediate: true });
onUnmounted(() => {