diff --git a/apps/web/src/chat/threads-api.test.ts b/apps/web/src/chat/threads-api.test.ts index 4ace15ac0..da57207ab 100644 --- a/apps/web/src/chat/threads-api.test.ts +++ b/apps/web/src/chat/threads-api.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { agentDeploySourceAssetName } from "../agent-deploy"; import { MYRA_SOURCE_CONFIG } from "../myra-source"; -import { displayAgentName } from "./threads-api"; +import { displayAgentName, resolveAvatarName, type RoomParticipant } from "./threads-api"; describe("displayAgentName", () => { test("renders Myra's fixed display name for her asset", () => { @@ -18,3 +18,34 @@ describe("displayAgentName", () => { expect(displayAgentName("some-other-asset")).toBe("some-other-asset"); }); }); + +describe("resolveAvatarName", () => { + const participants: readonly RoomParticipant[] = [ + { id: "p1", kind: "person", name: "alice", address: "alice@example.com" }, + { id: "a1", kind: "agent", name: "Myra", address: "myra@example.com" }, + ]; + + test("uses the person's own real name for their own turn, never 'You'", () => { + const name = resolveAvatarName( + { author: "me", authorName: "You", address: "alice@example.com" }, + participants, + ); + expect(name).toBe("alice"); + }); + + test("uses the matching participant's name for another author", () => { + const name = resolveAvatarName( + { author: "other", authorName: "myra", address: "myra@example.com" }, + participants, + ); + expect(name).toBe("Myra"); + }); + + test("falls back to authorName when no participant matches", () => { + const name = resolveAvatarName( + { author: "other", authorName: "someone", address: "unknown@example.com" }, + participants, + ); + expect(name).toBe("someone"); + }); +}); diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index f18240dff..206a9574f 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -687,6 +687,18 @@ export function resolveParticipantName( ); } +/** A turn's avatar name: the matching participant's real name, including + * the person's own — never the "You" transcript label, which stays for the + * row's own text elsewhere. Falls back to `resolveParticipantName` when no + * participant matches the turn's address. */ +export function resolveAvatarName( + message: Pick, + participants: readonly RoomParticipant[], +): string { + const matched = participants.find((participant) => participant.address === message.address); + return matched?.name ?? resolveParticipantName(message, participants); +} + type RoomTurn = { readonly id: string; readonly messageId: string; diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index 6a4648da9..abbd8839a 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -29,6 +29,7 @@ import { ancestorChain, listRoomParticipants, readRoom, + resolveAvatarName, resolveParticipantName, sendToRoom, subscribeToInbox, @@ -60,9 +61,10 @@ function RoomMessageRow({ /** Undefined in the sub-thread panel, where a row is read-only context. */ readonly onReply?: (message: RoomMessage) => void; }) { - // Avatars read off the participant's display name (Myra → "M"), never the - // run address local part a mail turn otherwise carries. - const displayName = resolveParticipantName(message, participants); + // Avatars read off the participant's real name — the person's own + // included, never the "You" transcript label — falling back to the + // address local part a mail turn otherwise carries. + const avatarName = resolveAvatarName(message, participants); const matched = participants.find((participant) => participant.address === message.address); const kind = message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; // The person's own send carries a trailing roster block so agents in the @@ -75,7 +77,7 @@ function RoomMessageRow({