From 4eeedb38fbd1c3e893f9780b085b4549a4de717e Mon Sep 17 00:00:00 2001 From: Jason Tudisco Date: Wed, 27 May 2026 00:27:33 -0600 Subject: [PATCH] feat(kez-chat/web): auto-scroll thread on new message + on conversation open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two rules, picked to feel natural: 1. Conversation just opened (peer_primary changed) → always scroll to bottom. You expect to see the latest exchange first. 2. New message landed in the current conversation → scroll to bottom IF you were already within 120px of the bottom. If you're scrolled up reading older history, the auto-scroll doesn't yank you back down. (Slack / Telegram / iMessage all do this; getting yanked out of history when you're searching for something is infuriating.) Implementation: a single $effect tracks activeConv.messages.length + activeConv.peer_primary. Compares against two cursor vars (prevPrimary, prevMessageCount) to distinguish "opened a new conversation" from "new message in current one". queueMicrotask after the DOM updates so scrollHeight reflects the just-rendered message. Co-Authored-By: Claude Opus 4.7 --- kez-chat/web/src/routes/Messages.svelte | 46 ++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/kez-chat/web/src/routes/Messages.svelte b/kez-chat/web/src/routes/Messages.svelte index 5a0d9bd..ace91e6 100644 --- a/kez-chat/web/src/routes/Messages.svelte +++ b/kez-chat/web/src/routes/Messages.svelte @@ -77,6 +77,50 @@ /** Unsubscribe handle for the inbox-service "new message" listener. */ let unsubscribe: (() => void) | null = null; + /** Scroll container for the thread view — bound for auto-scroll. */ + let scrollEl: HTMLDivElement | null = $state(null); + /** Tracks the previously-rendered conversation so we know when the + * user just OPENED a different thread (always scroll to bottom) vs. + * when a new message landed in the current one (scroll only if they + * were near the bottom already — don't yank them out of history). */ + let prevPrimary: string | null = null; + let prevMessageCount = 0; + + /** Distance (px) from the bottom of the thread within which we still + * count "near bottom" for the follow-along rule. Roughly two short + * bubble heights — generous enough that small misses don't sticky. */ + const SCROLL_FOLLOW_THRESHOLD_PX = 120; + + // Auto-scroll effect — runs whenever activeConv.messages.length or + // activeConv.peer_primary changes (both are reactive reads). + $effect(() => { + if (!activeConv || !scrollEl) { + prevPrimary = null; + prevMessageCount = 0; + return; + } + const el = scrollEl; + const count = activeConv.messages.length; + const conversationChanged = activeConv.peer_primary !== prevPrimary; + if (conversationChanged) { + prevPrimary = activeConv.peer_primary; + prevMessageCount = count; + // Wait a microtask so Svelte's DOM updates have flushed. + queueMicrotask(() => (el.scrollTop = el.scrollHeight)); + return; + } + if (count > prevMessageCount) { + prevMessageCount = count; + const distFromBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + if (distFromBottom < SCROLL_FOLLOW_THRESHOLD_PX) { + queueMicrotask(() => (el.scrollTop = el.scrollHeight)); + } + } else { + prevMessageCount = count; + } + }); + // "Start chat with" lookup state. let newPeerInput = $state(""); let resolving = $state(false); @@ -351,7 +395,7 @@

-
+
{#each activeConv.messages as m (m.seq + ":" + m.direction)} {@const boost = emojiOnlyBoost(m.body)}