Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 113 additions & 14 deletions src/lib/actions/snapScrollToBottom.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,143 @@
import { navigating } from "$app/state";
import { tick } from "svelte";

const detachedOffset = 10;
// Threshold for considering user "at bottom" - generous to handle small layout shifts
const BOTTOM_THRESHOLD = 50;
// Minimum scroll distance to consider intentional user scrolling up (low to allow easy detachment)
const SCROLL_UP_THRESHOLD = 5;

/**
* Checks if the scroll container is at or near the bottom
*/
function isAtBottom(node: HTMLElement): boolean {
const { scrollTop, scrollHeight, clientHeight } = node;
return scrollHeight - scrollTop - clientHeight <= BOTTOM_THRESHOLD;
}

/**
* @param node element to snap scroll to bottom
* @param dependency pass in a dependency to update scroll on changes.
*/
export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
let prevScrollValue = node.scrollTop;
let isDetached = false;
// Track whether user has intentionally scrolled away from bottom
let isUserDetached = false;
// Track previous scroll position to detect user scroll direction
let prevScrollTop = node.scrollTop;
// Track previous content height to detect content growth vs user scroll
let prevScrollHeight = node.scrollHeight;
// Track if we're programmatically scrolling to avoid false detach
let isProgrammaticScroll = false;
// ResizeObserver to watch for content height changes
let resizeObserver: ResizeObserver | null = null;
// Track recent user scroll activity to avoid fighting with user input
let lastUserScrollTime = 0;
const USER_SCROLL_COOLDOWN = 150; // ms to wait after user scroll before auto-scrolling

const scrollToBottom = () => {
isProgrammaticScroll = true;
node.scrollTo({ top: node.scrollHeight });
// Reset flag after scroll completes
requestAnimationFrame(() => {
isProgrammaticScroll = false;
prevScrollTop = node.scrollTop;
prevScrollHeight = node.scrollHeight;
});
};

const handleScroll = () => {
// if user scrolled up, we detach
if (node.scrollTop < prevScrollValue) {
isDetached = true;
// Ignore programmatic scrolls
if (isProgrammaticScroll) {
return;
}

const currentScrollTop = node.scrollTop;
const scrollDelta = currentScrollTop - prevScrollTop;
const contentGrew = node.scrollHeight > prevScrollHeight;

// Record any user scroll activity (for cooldown mechanism)
if (Math.abs(scrollDelta) > 1) {
lastUserScrollTime = Date.now();
}

// If content grew while we were at the bottom, stay attached
if (contentGrew && !isUserDetached) {
prevScrollTop = currentScrollTop;
prevScrollHeight = node.scrollHeight;
return;
}

// if user scrolled back to within 10px of bottom, we reattach
if (node.scrollTop - (node.scrollHeight - node.clientHeight) >= -detachedOffset) {
isDetached = false;
// User scrolled up - detach (low threshold for responsive feel)
if (scrollDelta < -SCROLL_UP_THRESHOLD) {
isUserDetached = true;
}

prevScrollValue = node.scrollTop;
// User scrolled back to bottom - reattach
if (isAtBottom(node)) {
isUserDetached = false;
}

prevScrollTop = currentScrollTop;
prevScrollHeight = node.scrollHeight;
};

const handleContentResize = () => {
// If user is not detached, scroll to stay at bottom
if (!isUserDetached) {
// Use requestAnimationFrame to batch with browser's layout
requestAnimationFrame(() => {
// Don't auto-scroll if user was recently scrolling (avoid fighting with user input)
const timeSinceUserScroll = Date.now() - lastUserScrollTime;
if (timeSinceUserScroll < USER_SCROLL_COOLDOWN) {
prevScrollHeight = node.scrollHeight;
return;
}

// Only scroll if still not detached and not already at bottom
if (!isUserDetached && !isAtBottom(node)) {
scrollToBottom();
}
prevScrollHeight = node.scrollHeight;
});
}
};

const updateScroll = async (_options: { force?: boolean } = {}) => {
const defaultOptions = { force: false };
const options = { ...defaultOptions, ..._options };
const { force } = options;

if (!force && isDetached && !navigating.to) return;
// Don't scroll if user has detached (unless forcing or navigating)
if (!force && isUserDetached && !navigating.to) return;

// wait for next tick to ensure that the DOM is updated
// Wait for DOM to update
await tick();
// Additional frame to ensure layout is complete
await new Promise((resolve) => requestAnimationFrame(resolve));

node.scrollTo({ top: node.scrollHeight });
scrollToBottom();
};

node.addEventListener("scroll", handleScroll);
// Set up scroll listener
node.addEventListener("scroll", handleScroll, { passive: true });

// Set up ResizeObserver to watch for content changes
// This catches expanding thinking blocks, streaming content, etc.
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
handleContentResize();
});

// Observe the scroll container's first child (the content wrapper)
// to detect when content inside changes size
const contentWrapper = node.firstElementChild;
if (contentWrapper) {
resizeObserver.observe(contentWrapper);
}
// Also observe the container itself
resizeObserver.observe(node);
}

// Initial scroll if there's a dependency
if (dependency) {
updateScroll({ force: true });
}
Expand All @@ -48,6 +146,7 @@ export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
update: updateScroll,
destroy: () => {
node.removeEventListener("scroll", handleScroll);
resizeObserver?.disconnect();
},
};
};
29 changes: 20 additions & 9 deletions src/lib/components/ScrollToBottomBtn.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
import { fade } from "svelte/transition";
import IconChevron from "./icons/IconChevron.svelte";

// Threshold for showing the button - matches snapScrollToBottom's BOTTOM_THRESHOLD
const VISIBILITY_THRESHOLD = 100;

interface Props {
scrollNode: HTMLElement;
scrollNode: HTMLElement | undefined;
class?: string;
}

Expand All @@ -14,33 +17,41 @@

function updateVisibility() {
if (!scrollNode) return;
visible =
Math.ceil(scrollNode.scrollTop) + 200 < scrollNode.scrollHeight - scrollNode.clientHeight;
const { scrollTop, scrollHeight, clientHeight } = scrollNode;
// Show button when user has scrolled up more than the threshold
visible = scrollHeight - scrollTop - clientHeight > VISIBILITY_THRESHOLD;
}

function destroy() {
observer?.disconnect();
scrollNode?.removeEventListener("scroll", updateVisibility);
}
const cleanup = $effect.root(() => {

$effect.root(() => {
$effect(() => {
if (scrollNode) {
if (window.ResizeObserver) {
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => updateVisibility());
observer.observe(scrollNode);
cleanup();
// Also observe content for size changes
const contentWrapper = scrollNode.firstElementChild;
if (contentWrapper) {
observer.observe(contentWrapper);
}
}
scrollNode?.addEventListener("scroll", updateVisibility);
scrollNode.addEventListener("scroll", updateVisibility, { passive: true });
// Initial visibility check
updateVisibility();
}
});
return () => destroy();
});
</script>

{#if visible}
{#if visible && scrollNode}
<button
transition:fade={{ duration: 150 }}
onclick={() => scrollNode.scrollTo({ top: scrollNode.scrollHeight, behavior: "smooth" })}
onclick={() => scrollNode?.scrollTo({ top: scrollNode.scrollHeight, behavior: "smooth" })}
class="btn absolute flex h-[41px] w-[41px] rounded-full border bg-white shadow-md transition-all hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:shadow-gray-950 dark:hover:bg-gray-600 {className}"
><IconChevron classNames="mt-[2px]" /></button
>
Expand Down
22 changes: 16 additions & 6 deletions src/lib/components/ScrollToPreviousBtn.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
import { onDestroy, untrack } from "svelte";
import IconChevron from "./icons/IconChevron.svelte";

// Threshold for showing the button - consistent with ScrollToBottomBtn
const VISIBILITY_THRESHOLD = 100;

let visible = $state(false);
interface Props {
scrollNode: HTMLElement;
scrollNode: HTMLElement | undefined;
class?: string;
}

Expand All @@ -14,9 +17,9 @@

function updateVisibility() {
if (!scrollNode) return;
visible =
Math.ceil(scrollNode.scrollTop) + 200 < scrollNode.scrollHeight - scrollNode.clientHeight &&
scrollNode.scrollTop > 200;
const { scrollTop, scrollHeight, clientHeight } = scrollNode;
// Show when scrolled up more than threshold AND not at the very top
visible = scrollHeight - scrollTop - clientHeight > VISIBILITY_THRESHOLD && scrollTop > 100;
}

function scrollToPrevious() {
Expand Down Expand Up @@ -54,13 +57,20 @@
if (scrollNode) {
destroy();

if (window.ResizeObserver) {
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => {
updateVisibility();
});
observer.observe(scrollNode);
// Also observe content for size changes
const contentWrapper = scrollNode.firstElementChild;
if (contentWrapper) {
observer.observe(contentWrapper);
}
}
scrollNode.addEventListener("scroll", updateVisibility);
scrollNode.addEventListener("scroll", updateVisibility, { passive: true });
// Initial visibility check
updateVisibility();
}
});
});
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/chat/ChatWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -587,9 +587,9 @@
{#if !messages.length}
<span>Generated content may be inaccurate or false.</span>
{/if}
</div>
</div>
</div>
</div>
</div>

<style lang="postcss">
Expand Down
Loading