From 5e3faf443a26cb9d1ca2de7a1ac96efe7701168c Mon Sep 17 00:00:00 2001 From: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Date: Mon, 24 Aug 2026 11:18:24 -0700 Subject: [PATCH] fix(desktop): never show a trigger title as the prompt author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation variant's author row resolved its name through `resolveUserLabel({ fallbackName: item.title })`, whose last fallback is the prompt item's title. That title describes the *trigger* that started the turn — "@Mention", "Prompt", "Buzz event", or any title-cased Buzz event kind — and is never an identity. On `default`/`compactPreview` it only seeds avatar initials, so it was harmless; the conversation author row promotes it to displayed text, so an unresolved sender was rendered as if "@Mention" were their name. Identity resolution for the displayed author now stops at the profile: display name, then NIP-05 handle, then the truncated pubkey. A truncated pubkey is terse but true, and it keeps the utterance attributed in a full-cover view where an unlabelled bubble would read as orphaned. The title stays available as trigger chrome in the footer. The avatar seed keeps the old chain verbatim, so `default` and `compactPreview` markup is untouched — the byte-for-byte fixture still passes unchanged. The existing author-row test was asserting the bug: its expectation came from `item.title`, with no profile in the lookup. It now supplies a profile and asserts the resolved display name, and the fixture's prompt title is the realistic "@Mention" rather than a person's name so no test can silently depend on a title reading as an identity again. Two new tests cover the unresolved sender (truncated pubkey, and an explicit assertion that no trigger word appears) and NIP-05-only resolution. Non-vacuous: reverting the fix fails the unresolved-sender test — and only that test — with `actual: '@Mention'`. Verified at this tree: full desktop suite 5381 passing / 0 failing, typecheck clean, `pnpm check` findings identical to main's baseline, file-size ratchet clean. Transcript file also green under UTC/de-DE and Asia/Tokyo/ja-JP. Signed-off-by: ss-dev-01 <11939edb7df583f855dbef923f2358f1184538f88ca452e19e7e35e42ad6d796@buzz.block.builderlab.xyz> Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- ...essionTranscriptList.conversation.test.mjs | 67 ++++++++++++++++++- .../UserMessageBubble.tsx | 20 +++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs index e1c5378141..80afc41e84 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.conversation.test.mjs @@ -139,6 +139,23 @@ const AGENT = { agentPubkey: "f".repeat(64), }; const AUTHOR = "a".repeat(64); +const AUTHOR_TRUNCATED = `${AUTHOR.slice(0, 8)}…${AUTHOR.slice(-4)}`; +/** + * What the transcript builder actually puts in a prompt item's `title`: a + * description of the trigger that started the turn, not an identity. Real values + * are "Prompt", "Buzz event", and title-cased event kinds like "@Mention" + * (`agentSessionTranscriptHelpers.ts` `parsePromptText`). The author row must + * never display this as a name. + */ +const TRIGGER_TITLE = "@Mention"; +const AUTHOR_PROFILES = { + [AUTHOR]: { + displayName: "Ada Lovelace", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, +}; function items() { const shared = { channelId: "chan-1", sessionId: "sess-1", turnId: "turn-1" }; @@ -149,7 +166,7 @@ function items() { type: "message", renderClass: "message", role: "user", - title: "Ada", + title: TRIGGER_TITLE, text: "please summarize the plan", timestamp: "2026-06-14T19:00:00.000Z", messageId: "event-1", @@ -426,12 +443,14 @@ test("conversation marks the transcript container and centers a reading column", }); test("conversation renders the prompt as a filled right-aligned bubble with an author label", async () => { - const { container } = await renderTranscript("conversation"); + const { container } = await renderTranscript("conversation", { + profiles: AUTHOR_PROFILES, + }); const author = container.querySelector( '[data-testid="transcript-user-message-author"]', ); assert.ok(author, "conversation should label the prompt author"); - assert.equal(author.textContent, "Ada"); + assert.equal(author.textContent, "Ada Lovelace"); const row = container.querySelector( '[data-testid="transcript-user-message"]', @@ -443,6 +462,48 @@ test("conversation renders the prompt as a filled right-aligned bubble with an a assert.doesNotMatch(bubble.className, /max-h-36/); }); +test("conversation never shows the trigger title as the prompt author when the sender is unresolved", async () => { + // Regression guard. The label chain's last fallback used to be the prompt + // item's `title`, which is a description of the trigger ("@Mention", + // "Prompt", "Buzz event") rather than an identity. On the other variants that + // string only seeds avatar initials, so it was harmless; the conversation + // author row displays it, so an unresolved sender was rendered as "@Mention" + // — a trigger presented as a person's name. With no profile for the author, + // the row must fall back to the truncated pubkey instead. + const { container } = await renderTranscript("conversation"); + const author = container.querySelector( + '[data-testid="transcript-user-message-author"]', + ); + assert.ok(author, "the author row should still render without a profile"); + assert.equal(author.textContent, AUTHOR_TRUNCATED); + assert.doesNotMatch( + author.textContent, + /@Mention|Prompt|Buzz event/, + "the trigger title must never be displayed as an author name", + ); +}); + +test("conversation prefers the resolved profile over both the trigger title and the pubkey", async () => { + // The truncated-pubkey fallback must not shadow a real identity: with a + // profile present the row shows the display name, and with only a NIP-05 + // handle it shows that. + const { container } = await renderTranscript("conversation", { + profiles: { + [AUTHOR]: { + displayName: null, + avatarUrl: null, + nip05Handle: "ada@example.com", + ownerPubkey: null, + }, + }, + }); + assert.equal( + container.querySelector('[data-testid="transcript-user-message-author"]') + .textContent, + "ada@example.com", + ); +}); + test("conversation renders agent messages as unboxed prose at full fidelity", async () => { const { container } = await renderTranscript("conversation"); const message = container.querySelector( diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 979bcd2917..20f0ff1f44 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -50,13 +50,31 @@ export function UserMessageBubble({ const authorProfile = item.authorPubkey ? profiles?.[item.authorPubkey.toLowerCase()] : null; - const authorLabel = item.authorPubkey + // The other variants seed the avatar from `resolveUserLabel(…, fallbackName: + // item.title)`, whose last resort is the prompt item's title. That title + // describes the *trigger* that started the turn ("@Mention", "Prompt", "Buzz + // event"), never a person — harmless when it only picks avatar initials, so + // this chain is left exactly as it was for `default`/`compactPreview`. + const triggerSeededLabel = item.authorPubkey ? resolveUserLabel({ pubkey: item.authorPubkey, fallbackName: item.title, profiles, }) : item.title || "User"; + // Focus mode promotes the author to displayed text, where a trigger title + // would read as a false identity — an unresolved sender showing up as + // "@Mention". Identity resolution here therefore stops at the profile + // (display name, then NIP-05 handle, then the truncated pubkey): a truncated + // pubkey is a real, if terse, identity, and it keeps the utterance attributed + // in a full-cover view. `item.title` stays available as trigger chrome in the + // footer; it is never a name. Only when the item carries no author at all does + // the row fall back to a generic placeholder. + const authorLabel = isConversation + ? item.authorPubkey + ? resolveUserLabel({ pubkey: item.authorPubkey, profiles }) + : "User" + : triggerSeededLabel; const handleBubbleClick = React.useCallback( (event: React.MouseEvent) => { if (!messageLink || isNestedInteractiveTarget(event)) return;