diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx
new file mode 100644
index 000000000..badd7f82c
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/chat-bubble-initials.test.tsx
@@ -0,0 +1,52 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+/**
+ * The person's bubble falls back to initials of the name the app shows, like
+ * the top bar, never the login: a managed record's login is a generated
+ * handle, and "MA" for `managed-…` is nobody's initials.
+ */
+const auth = vi.hoisted(() => ({
+ user: {
+ id: "u1",
+ username: "managed-1f2e3d",
+ displayName: "Alex Example" as string | null,
+ role: "USER",
+ avatarUrl: null,
+ },
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ user: auth.user, isAuthenticated: true, isLoading: false }),
+}));
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ data: undefined }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+
+import { ChatBubble } from "../chat-bubble";
+
+function userBubble(): string {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+describe("coach bubble initials", () => {
+ it("spells the display name, not the login", () => {
+ const html = userBubble();
+ expect(html).toContain(">AE<");
+ expect(html).not.toContain(">MA<");
+ });
+
+ it("falls back to the login when there is no display name", () => {
+ auth.user.displayName = null;
+ auth.user.username = "sam";
+ expect(userBubble()).toContain(">S<");
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
index f26660ff9..9c28c40b1 100644
--- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
@@ -551,10 +551,11 @@ describe("", () => {
});
it("falls back to initials when no avatar URL is present", () => {
- // Default mock returns avatarUrl: null → initials path.
+ // Default mock returns avatarUrl: null → initials path, spelled like the
+ // top bar's: one word, one letter.
const html = render();
expect(html).toContain('data-slot="coach-bubble-user-avatar"');
- expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>TE);
+ expect(html).toMatch(/data-slot="coach-bubble-user-avatar"[^>]*>T);
});
// v1.4.25 — optimistic user bubble appears before the "Thinking…"
diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx
index 21f341c8f..a150b6abc 100644
--- a/src/components/insights/coach-panel/chat-bubble.tsx
+++ b/src/components/insights/coach-panel/chat-bubble.tsx
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
+import { accountName, nameInitials } from "@/lib/account-name";
import {
parseChartTokens,
tokenToMetric,
@@ -388,12 +389,10 @@ function ChatBubbleImpl({
if (role === "user") {
// v1.5.5 — pull the user's self-hosted avatar so the user
// bubble matches the Coach avatar in size and visual weight.
- // Falls back to initials when the user has not uploaded an
- // avatar. Replaces the Gravatar leak (v1.4.22 B3).
+ // Falls back to the display name's initials, as the top bar does, when
+ // the user has not uploaded an avatar.
const avatarUrl = user?.avatarUrl ?? null;
- const initials = user?.username
- ? user.username.slice(0, 2).toUpperCase()
- : null;
+ const initials = user ? nameInitials(accountName(user)) || null : null;
return (