eleven / messenger Public

web: scroll to the newest message when you send (#218)

Sending had no optimistic echo, so a message only appeared on the server
round-trip's re-render — and render() decides whether to pin the feed to the
bottom by reading the *current* scroll position. If you'd scrolled up to read or
reply to an older message, that check was false, so your sent message landed
off-screen and the feed stayed put.

Arm a per-thread pinNext flag on a successful send (reply, plain text,
attachments, and native-embed sends all route through the same submit), and have
render() consume it — Set.delete reads and clears in one call, so it forces
exactly one render and self-clears. A forced pin jumps instantly rather than
gliding, so a send from far up the history lands on your message immediately
instead of smooth-scrolling the whole thread.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 10549edf14ccb50efd9b539b68a48718cb660a47 parent 2757fd0
2 files changed, +22 −4
  • changelog/2026-07-05-send-scrolls-to-bottom.md +1 −0
  • web/static/apps/chat.js +21 −4
diff --git a/changelog/2026-07-05-send-scrolls-to-bottom.md b/changelog/2026-07-05-send-scrolls-to-bottom.md
new file mode 100644
index 0000000..8f39e4d
--- /dev/null
+++ b/changelog/2026-07-05-send-scrolls-to-bottom.md
@@ -0,0 +1 @@
+Sending a message now always drops you to the bottom of the conversation, so your message is right there in view — even if you'd scrolled up to read or reply to something older. Replies from up the thread land you back at the newest message instead of leaving you stranded where you were reading.
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 0d18df7..1c22317 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -111,6 +111,14 @@ const myReactions = new Set();
// Edit) can reach the composer they share a pane with.
const composers = new Map(); // threadId → { refreshSend(), startReply(item), startEdit(item) }
+// Threads whose next render should snap to the newest message even if the reader
+// had scrolled up — set when you send from the composer (a reply included), since
+// there's no optimistic echo: your message only lands on the server round-trip's
+// re-render, by which time render()'s scroll-position check has forgotten you just
+// sent. render() consumes (and clears) this so a send always brings your message
+// into view, like every familiar chat client.
+const pinNext = new Set(); // threadId
+
// Whether the socket is currently open. chat.js flips this on ws open/close so
// each composer can grey its send button while a message can't reach the server
// — a send while down is kept in the composer, never dropped (see compose.onsubmit).
@@ -370,7 +378,10 @@ export const app = defineApp({
});
const feed = root.querySelector(".chatapp-feed");
- const wasAtBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60 || !feed.childElementCount;
+ // A local send arms pinNext: snap to the newest even if we'd scrolled up.
+ // Set.delete both reads and clears it, so it only forces this one render.
+ const forcePin = pinNext.delete(ctx.threadId);
+ const wasAtBottom = forcePin || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60 || !feed.childElementCount;
const msgs = (state && state.msgs) || [];
// A sender's message arriving means they've stopped typing — clear their
@@ -405,9 +416,11 @@ export const app = defineApp({
update: (node, m) => patchBubble(node, m, ctx),
});
if (wasAtBottom) {
- // History builds jump straight to the newest; a live append glides (the
- // original ease), respecting reduced-motion.
- if (firstBuild || matchMedia("(prefers-reduced-motion: reduce)").matches) {
+ // History builds — and a local send from scrolled-up (forcePin) — jump
+ // straight to the newest; a plain live append glides (the original ease),
+ // respecting reduced-motion. A send jumps rather than gliding the whole
+ // history so it lands instantly on your message, not after a long scroll.
+ if (firstBuild || forcePin || matchMedia("(prefers-reduced-motion: reduce)").matches) {
feed.scrollTop = feed.scrollHeight;
} else {
feed.scrollTo({ top: feed.scrollHeight, behavior: "smooth" });
@@ -2726,6 +2739,10 @@ function buildSkeleton(el, ctx) {
// files, and reply target intact to resend — never silently drop what was typed.
ctx.emit(payload).then((sent) => {
if (!sent) return;
+ // Snap to the newest on the echo's re-render — your message should land in
+ // view even if you'd scrolled up to reply. Set now (well before the server
+ // round-trip); render() clears it.
+ pinNext.add(ctx.threadId);
resetInput();
pending = [];
cancelReply();