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)}