| 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(); |