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>
3 files changed,
+13
−3
test/format.test.mjs+6 −0web/static/chat.js+4 −1web/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 ""; |
| } |