web: repaint already-rendered @mentions when a name newly resolves (#1041)
* web: repaint already-rendered @mentions when a name newly resolves
Root cause of #249 ("@Jordi not being bold" after they join/get
added): renderRichText (chat.js) scans a message's text for @mentions
against host.mentionNames() correctly, fresh, every time it runs —
but chat.js's keyedList only calls render() once per message id. A
message that rendered before Jordi's roster label was known stayed
plain text forever; nothing ever revisited that row once drawn, no
matter how current the roster later became.
decryptRosterLabels (shell.js) now bumps a mentionEpoch counter
whenever a roster label newly resolves (a fresh joiner, or a label
that just finished decrypting) — the one function every path that can
reveal a new mentionable name funnels through, whether triggered by
loadRoster, the "joined" broadcast, or a late-arriving instance key.
A new host.mentionEpoch() bridge exposes it; chat.js's mentionRev()
folds it into every row's rev (alongside the existing mutedRev
pattern), so a roster change forces exactly one repaint of the
visible feed, and never otherwise.
Fixes #249
Co-Authored-By: Claude <noreply@anthropic.com>
* web: scope the mention repaint to affected messages, not the whole feed
Revises the previous commit per review feedback: a global mentionEpoch
consulted by every message's rev forced keyedList to rebuild the
ENTIRE visible feed whenever any name became newly mentionable — full
DOM node replacement for messages that have nothing to do with the
change, risking exactly the flicker/scroll-jump this fix was meant to
avoid.
decryptRosterLabels now hands the newly-resolved names straight to
chat.js's new repaintMentionsOf, which scans each mounted thread's
last 100 messages for ones that actually reference one of those
names, marks only their sids dirty, and triggers one targeted
re-render — mentionRev(m) reads that per-message mark instead of a
global counter. Everything else in the feed is left untouched, same
as mutedRev's existing scoped-by-sender pattern right above it.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
3 files changed,
+57
−3
changelog/2026-07-16-live-mention-repaint.md+3 −0web/static/apps/chat.js+41 −2web/static/shell.js+13 −1
diff --git a/changelog/2026-07-16-live-mention-repaint.md b/changelog/2026-07-16-live-mention-repaint.md| new file mode 100644 |
| index 0000000..4f9a9f8 |
| --- /dev/null |
| +++ b/changelog/2026-07-16-live-mention-repaint.md |
| @@ -0,0 +1,3 @@ |
| +🤖 A message mentioning someone who joins (or gets added) moments later no |
| +longer stays plain, un-bolded text — it now picks up the `@mention` styling |
| +live, without needing a reload. |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index e83fce9..5df987c 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -729,7 +729,7 @@ export const app = defineApp({ |
| if (introText) items.unshift({ id: "chatapp-intro", intro: true, text: introText, vrev: "intro|" + introText }); |
| keyedList(feed, items, { |
| key: (m) => m.id, |
| - rev: (m) => m.vrev + mutedRev(m, ctx), |
| + rev: (m) => m.vrev + mutedRev(m, ctx) + mentionRev(m), |
| srev: (m) => m.srev, |
| render: (m) => renderItem(m, ctx), |
| update: (node, m) => patchBubble(node, m, ctx), |
| @@ -822,6 +822,45 @@ function mutedRev(m, ctx) { |
| return revealedMuted.has(m.sid) ? "|m1" : "|m0"; |
| } |
| +// A message renders its @mentions against the roster AT RENDER TIME |
| +// (renderRichText reads host.mentionNames() fresh) — but keyedList only calls |
| +// render() once per key; an already-rendered bubble is never revisited unless |
| +// its rev changes. A person who joins (or is newly added to a group) after a |
| +// message mentioning them already rendered stays plain, unbolded text |
| +// forever, until something touches that row (issue #249). repaintMentionsOf |
| +// marks exactly the messages that need one (sids only); mentionRev reads that |
| +// mark so keyedList rebuilds those rows and leaves everything else alone — |
| +// bumping a global rev would force-rebuild the whole visible feed for a |
| +// change that's almost always irrelevant to it. |
| +const mentionDirty = new Set(); |
| +function mentionRev(m) { |
| + return mentionDirty.has(m.sid) ? "|mention" : ""; |
| +} |
| + |
| +// repaintMentionsOf(names): one or more roster labels just became known (a |
| +// fresh joiner, or a label that finished decrypting) — called from shell.js's |
| +// decryptRosterLabels. Scans each mounted thread's last 100 messages for ones |
| +// that actually reference a newly-known name and marks only those dirty, then |
| +// asks the thread to re-render — a small, targeted repaint instead of a |
| +// blanket one (issue #249). |
| +export function repaintMentionsOf(names) { |
| + if (!names || !names.length) return; |
| + for (const ctx of mountedCtx.values()) { |
| + const msgs = (ctx.getState() && ctx.getState().msgs) || []; |
| + let touched = false; |
| + for (const m of msgs.slice(-100)) { |
| + if (m.text && names.some((n) => m.text.includes("@" + n))) { |
| + mentionDirty.add(m.sid); |
| + touched = true; |
| + } |
| + } |
| + if (touched) { |
| + const refresh = refreshFeed.get(ctx.threadId); |
| + if (refresh) refresh(); |
| + } |
| + } |
| +} |
| + |
| // Swap the composer for the closed-conversation banner while the host says |
| // this DM is closed (issue #578). The banner occupies the composer's layout |
| // slot — inline, never floating over the feed. |
| @@ -3363,7 +3402,7 @@ function buildSkeleton(el, ctx) { |
| const threadKeyed = (container, items) => |
| keyedList(container, items, { |
| key: (m) => m.id, |
| - rev: (m) => m.vrev + mutedRev(m, ctx), |
| + rev: (m) => m.vrev + mutedRev(m, ctx) + mentionRev(m), |
| srev: (m) => m.srev, |
| render: (m) => renderItem(m, ctx), |
| update: (node, m) => patchBubble(node, m, ctx), |
diff --git a/web/static/shell.js b/web/static/shell.js| index 9f58cbe..e6b5a60 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -133,7 +133,7 @@ import { planQuickForm } from "/static/quickform.js"; // "Fill in a form instead |
| import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admin or setup's chip) |
| import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md) |
| import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md) |
| -import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage } from "/static/apps/chat.js"; |
| +import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf } from "/static/apps/chat.js"; |
| import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js"; |
| import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; |
| import { editGroup, openOrCreateDM, showMembers, viewProfile, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js"; |
| @@ -1578,6 +1578,7 @@ async function verifyInviteBindings() { |
| // plaintext .label they still have as a fallback. |
| async function decryptRosterLabels() { |
| if (!instanceKey) return; |
| + const before = new Set(roster.map((e) => e.label).filter(Boolean)); |
| await Promise.all( |
| roster.map(async (e) => { |
| if (e.label_blob) { |
| @@ -1598,6 +1599,17 @@ async function decryptRosterLabels() { |
| }), |
| ); |
| backfillLegacyLabels(); |
| + // A chat message is scanned for @mentions against host.mentionNames() only |
| + // once, when it first renders (chat.js's keyedList never revisits an |
| + // unchanged row) — so a message mentioning someone who wasn't yet |
| + // mentionable at render time stays plain, unbolded text forever unless |
| + // something repaints it. A roster label newly resolving here — a fresh |
| + // joiner, or a name that just finished decrypting — is exactly that |
| + // moment; hand the newly-known names to chat.js so it can repaint just the |
| + // messages that actually reference one (issue #249). |
| + const newNames = []; |
| + for (const e of roster) if (e.label && !before.has(e.label)) newNames.push(e.label); |
| + if (newNames.length) repaintMentionsOf(newNames); |
| } |
| // Seal legacy plaintext names (docs/no-plaintext.md): a person whose name |