feat(kez-chat/web): auto-scroll thread on new message + on conversation open

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 <noreply@anthropic.com>
This commit is contained in:
Jason Tudisco 2026-05-27 00:27:33 -06:00
parent de120f7d6c
commit 4eeedb38fb

View File

@ -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 @@
</p>
</div>
<div class="flex-1 overflow-y-auto p-4 space-y-2">
<div class="flex-1 overflow-y-auto p-4 space-y-2" bind:this={scrollEl}>
{#each activeConv.messages as m (m.seq + ":" + m.direction)}
{@const boost = emojiOnlyBoost(m.body)}
<div class={`max-w-md ${m.direction === "out" ? "ml-auto" : ""}`}>