From 026903399be73ac4b6c226647110e5662d043a50 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 13 Apr 2026 15:58:33 -0500 Subject: [PATCH] refac --- src/lib/components/chat/Messages.svelte | 125 ++++++++++++++++++++++-- 1 file changed, 119 insertions(+), 6 deletions(-) diff --git a/src/lib/components/chat/Messages.svelte b/src/lib/components/chat/Messages.svelte index e8ada5c57..feabd0ffa 100644 --- a/src/lib/components/chat/Messages.svelte +++ b/src/lib/components/chat/Messages.svelte @@ -57,18 +57,119 @@ export let onSelect = (e) => {}; - export let messagesCount: number | null = 20; + export let messagesCount: number | null = 10; let messagesLoading = false; + // Off-screen message unloading. Heights are measured on scroll so spacers + // always match real sizes — no scroll jumps, no feedback loops needed. + const OVERSCAN = 3; + const DEFAULT_HEIGHT = 150; + let visibleStart = 0; + let visibleEnd = 0; + let messageHeights = new Map(); + let topSpacerHeight = 0; + let bottomSpacerHeight = 0; + let pendingCull = null; + + // Helper: get height for a message (cached or default) + const heightOf = (id) => messageHeights.get(id) ?? DEFAULT_HEIGHT; + + /** Measure all currently rendered message elements and cache their heights */ + const measureMessageHeights = () => { + const elements = document.getElementById('messages-container')?.querySelectorAll('[role="listitem"]'); + if (!elements) return; + + messageHeights = new Map([ + ...messageHeights, + ...Array.from(elements) + .map((el, i) => [messages[visibleStart + i]?.id, el.getBoundingClientRect().height]) + .filter(([id]) => id != null) + ]); + }; + + /** Compute visible range from current scroll position and apply */ + const updateVisibleRange = () => { + const container = document.getElementById('messages-container'); + if (!container || messages.length === 0) return; + + const st = container.scrollTop; + const ch = container.clientHeight; + + // Build prefix sums from measured heights + const prefixSums = messages.reduce( + (acc, m) => [...acc, acc[acc.length - 1] + heightOf(m.id)], + [0] + ); + + const firstVisible = Math.max(0, prefixSums.findIndex((h) => h > st) - 1); + const lastVisible = prefixSums.findIndex((h) => h > st + ch); + + // Only cull messages that have been measured (so spacer height is accurate) + // findIndex returns -1 when all are measured → no limit on culling + const firstUnmeasured = messages.findIndex((m) => !messageHeights.has(m.id)); + const cullLimit = firstUnmeasured === -1 ? messages.length : firstUnmeasured; + + visibleStart = Math.max(0, Math.min(firstVisible - OVERSCAN, cullLimit)); + visibleEnd = Math.min(messages.length, (lastVisible === -1 ? messages.length : lastVisible) + OVERSCAN); + topSpacerHeight = prefixSums[visibleStart] ?? 0; + bottomSpacerHeight = (prefixSums[messages.length] ?? 0) - (prefixSums[visibleEnd] ?? 0); + }; + + /** Scroll handler: measure every frame, cull via rAF (same throttle as pendingRebuild) */ + const handleContainerScroll = () => { + measureMessageHeights(); + + // Don't cull during progressive loading + if (messagesLoading) return; + + if (!pendingCull) { + pendingCull = requestAnimationFrame(() => { + pendingCull = null; + updateVisibleRange(); + }); + } + }; + + let scrollListenerAttached = false; + + const attachScrollListener = () => { + if (scrollListenerAttached) return; + const container = document.getElementById('messages-container'); + if (!container) return; + + container.addEventListener('scroll', handleContainerScroll, { passive: true }); + scrollListenerAttached = true; + }; + + onMount(() => { + attachScrollListener(); + }); + + onDestroy(() => { + const container = document.getElementById('messages-container'); + if (container && scrollListenerAttached) { + container.removeEventListener('scroll', handleContainerScroll); + } + cancelAnimationFrame(pendingCull); + cancelAnimationFrame(pendingRebuild); + }); + const loadMoreMessages = async () => { // scroll slightly down to disable continuous loading const element = document.getElementById('messages-container'); element.scrollTop = element.scrollTop + 100; messagesLoading = true; - messagesCount += 20; + messagesCount += 10; + buildMessages(); + // Show all messages during progressive loading (no culling) + visibleStart = 0; + visibleEnd = messages.length; + topSpacerHeight = 0; + bottomSpacerHeight = 0; + await tick(); messagesLoading = false; @@ -95,6 +196,7 @@ } messages = _messages.reverse(); + visibleEnd = messages.length; }; // Throttle message list rebuilds to once per animation frame during streaming. @@ -113,6 +215,8 @@ cancelAnimationFrame(pendingRebuild); pendingRebuild = null; buildMessages(); + // No explicit culling needed — scrollToBottom will fire a scroll event, + // which triggers handleContainerScroll → rAF → updateVisibleRange } else if (_messages) { // Content update (streaming) — throttle to once per frame if (!pendingRebuild) { @@ -426,9 +530,7 @@ showMessage({ id: parentMessageId }, false); }; - onDestroy(() => { - cancelAnimationFrame(pendingRebuild); - }); + const triggerScroll = () => { if (autoScroll) { @@ -465,7 +567,13 @@ {/if}