Render @mentions as chips in chat messages

Chat messages rendered markdown but never passed the mention props, so
@mentions stayed plain text. ChatMessageRow now resolves the message's
p/mention tags into name→pubkey maps (people vs agent chips, same
treatment as channel messages) for every author — own bubbles, other
humans, and agents. Mentioned users' pubkeys join the chat's profile
batch so a chip resolves even when that user never posted in the chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-07 07:51:26 +01:00
co-authored by Claude Fable 5
parent 48a9ff9742
commit b6af79309f
3 changed files with 63 additions and 0 deletions
@@ -5,6 +5,10 @@ import { cleanAssistantMessageText } from "@/features/chats/ui/chatActivityText"
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { RelayEvent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
resolveMentionNames,
resolveMentionPubkeysByName,
} from "@/shared/lib/resolveMentionNames";
import { Bubble } from "@/shared/ui/bubble";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
@@ -58,6 +62,28 @@ export function ChatMessageRow({
const content = isAgent
? cleanAssistantMessageText(event.content)
: event.content;
// @mentions render as chips, resolved from the message's p/mention tags —
// same treatment as channel messages, split into people vs agent chips.
const mentionNames = React.useMemo(
() => resolveMentionNames(event.tags, profiles),
[event.tags, profiles],
);
const mentionPubkeysByName = React.useMemo(
() => resolveMentionPubkeysByName(event.tags, profiles),
[event.tags, profiles],
);
const agentMentionPubkeysByName = React.useMemo(() => {
if (!mentionPubkeysByName) {
return undefined;
}
const values: Record<string, string> = {};
for (const [name, pubkey] of Object.entries(mentionPubkeysByName)) {
if (profiles?.[pubkey.toLowerCase()]?.isAgent) {
values[name] = pubkey;
}
}
return Object.keys(values).length > 0 ? values : undefined;
}, [mentionPubkeysByName, profiles]);
return (
<Message side={isOwn ? "right" : "left"}>
@@ -84,12 +110,16 @@ export function ChatMessageRow({
{isAgent ? (
<Markdown
agentAuthored
agentMentionPubkeysByName={agentMentionPubkeysByName}
className="w-full max-w-none text-sm leading-6"
content={content || " "}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
/>
) : (
<Bubble side={isOwn ? "right" : "left"}>
<Markdown
agentMentionPubkeysByName={agentMentionPubkeysByName}
className={cn(
"min-w-0",
isOwn &&
@@ -97,6 +127,8 @@ export function ChatMessageRow({
)}
compact
content={content || " "}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
/>
</Bubble>
)}
@@ -52,6 +52,7 @@ import { addChannelMembers, getCanvas, setCanvas } from "@/shared/api/tauri";
import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
type ChatsScreenProps = {
@@ -293,6 +294,15 @@ export function ChatsScreen({
identityQuery.data?.pubkey,
defaultAgent?.pubkey,
...messages.map((message) => message.pubkey),
// Mentioned users too — chips need the mentioned profile's display
// name even when that user never posted in the chat.
...messages.flatMap(
(message) =>
message.tags?.flatMap((tag) => {
const pubkey = getMentionTagPubkey(tag);
return pubkey ? [pubkey] : [];
}) ?? [],
),
]
.filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase()),
@@ -98,6 +98,7 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
channelName: string;
content: string;
createdAt?: number;
mentionPubkeys?: string[];
pubkey?: string;
}) => unknown;
};
@@ -117,10 +118,30 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
createdAt: base + 2,
pubkey: pubkey ?? undefined,
});
// A human message with a mention tag: @bob must render as a chip
// (alice's pubkey is a mock profile fixture; bob's resolves via the
// message's p tag).
win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "Hello Fizz, first message",
content: "Loop in @bob for the review.",
createdAt: base + 4,
mentionPubkeys: [
"bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260",
],
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
});
},
{ pubkey: fizzPubkey },
);
// Mentions in chat messages render as chips, same as channels.
await expect(
page.getByLabel("Chat messages").locator("[data-mention]", {
hasText: "bob",
}),
).toBeVisible({ timeout: 10_000 });
await expect(
page.getByRole("heading", { name: "First message" }),
).toBeVisible({ timeout: 10_000 });