eleven / messenger Public

web: reply follow-ups — cap attachment-name snippet, don't yank scroll on reply

Review follow-ups to the reply PR:

- replySnippet now caps the attachment-name branch at max (like the text branch), so an over-long filename can't bloat the snippet embedded in the payload (+ test).

- startReply only scrolls to the bottom if you were already there, so replying to a message you scrolled up to no longer yanks you down (the banner already shows the quote).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 735ffe2a5f3c3503c17942af2e6d55a649b7201d parent fd8f38e
3 files changed, +13 −3
  • test/format.test.mjs +6 −0
  • web/static/chat.js +4 −1
  • web/static/format.js +3 −2
diff --git a/test/format.test.mjs b/test/format.test.mjs
index 4da162c..8b96448 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -93,6 +93,12 @@ await test("replySnippet labels non-image attachments by name", () => {
assert.equal(replySnippet({ attachments: [{ type: "application/pdf", name: "spec.pdf" }] }), "📎 spec.pdf");
});
+await test("replySnippet caps an over-long attachment name", () => {
+ const s = replySnippet({ attachments: [{ type: "application/pdf", name: "x".repeat(200) }] }, 20);
+ assert.equal(s.length, 20);
+ assert.ok(s.startsWith("📎 ") && s.endsWith("…"));
+});
+
await test("replySnippet is empty for an empty message", () => {
assert.equal(replySnippet({}), "");
assert.equal(replySnippet(null), "");
diff --git a/web/static/chat.js b/web/static/chat.js
index 70c4f59..fd781fa 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -943,6 +943,9 @@ async function start(session) {
// above the composer. Independent of any staged attachment.
function startReply(meta) {
if (!meta) return;
+ // Keep the latest message pinned if you were already at the bottom, but don't
+ // yank you down if you scrolled up to reply to an older message.
+ const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80;
replyingTo = meta;
replyBox.classList.remove("hidden");
replyBox.innerHTML = "";
@@ -971,7 +974,7 @@ async function start(session) {
bar.appendChild(body);
bar.appendChild(x);
replyBox.appendChild(bar);
- feed.scrollTop = feed.scrollHeight;
+ if (atBottom) feed.scrollTop = feed.scrollHeight;
input.focus();
}
diff --git a/web/static/format.js b/web/static/format.js
index 445664c..d876fc3 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -44,13 +44,14 @@ export function tokenizeText(text) {
// only), falls back to a label for its first attachment so the quote still says
// something. Pure so the reply UI in chat.js stays testable.
export function replySnippet(payload, max = 140) {
+ const clamp = (s) => (s.length > max ? s.slice(0, max - 1).trimEnd() + "…" : s);
const text = (payload && payload.text ? String(payload.text) : "").replace(/\s+/g, " ").trim();
- if (text) return text.length > max ? text.slice(0, max - 1).trimEnd() + "…" : text;
+ if (text) return clamp(text);
const atts = payload && Array.isArray(payload.attachments) ? payload.attachments : [];
if (atts.length) {
const a = atts[0] || {};
if (a.type && a.type.startsWith("image/")) return "📷 Photo";
- return "📎 " + (a.name || "Attachment");
+ return clamp("📎 " + (a.name || "Attachment"));
}
return "";
}