From b6af79309f4f8dbc3db0f3f94090202ffcb3541c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 15:57:50 +0100 Subject: [PATCH] Render @mentions as chips in chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../chats/ui/ChatConversationRows.tsx | 32 +++++++++++++++++++ desktop/src/features/chats/ui/ChatsScreen.tsx | 10 ++++++ desktop/tests/e2e/chats-first-message.spec.ts | 21 ++++++++++++ 3 files changed, 63 insertions(+) diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index 9d291597c..c7c5416da 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -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 = {}; + 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 ( @@ -84,12 +110,16 @@ export function ChatMessageRow({ {isAgent ? ( ) : ( )} diff --git a/desktop/src/features/chats/ui/ChatsScreen.tsx b/desktop/src/features/chats/ui/ChatsScreen.tsx index 6c7ea72c0..855cefdcd 100644 --- a/desktop/src/features/chats/ui/ChatsScreen.tsx +++ b/desktop/src/features/chats/ui/ChatsScreen.tsx @@ -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()), diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 1e9c6d287..6d8bc4f8c 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -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 });