diff --git a/packages/views/editor/mention-name.test.tsx b/packages/views/editor/mention-name.test.tsx
new file mode 100644
index 0000000000..cd42d3b325
--- /dev/null
+++ b/packages/views/editor/mention-name.test.tsx
@@ -0,0 +1,95 @@
+import { describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+
+vi.mock("@multica/core/hooks", () => ({
+ useWorkspaceId: () => "ws-test",
+}));
+
+vi.mock("@multica/core/workspace/queries", () => ({
+ agentListOptions: (wsId: string) => ({
+ queryKey: ["workspaces", wsId, "agents"],
+ queryFn: async () => [],
+ }),
+ memberListOptions: (wsId: string) => ({
+ queryKey: ["workspaces", wsId, "members"],
+ queryFn: async () => [],
+ }),
+ squadListOptions: (wsId: string) => ({
+ queryKey: ["workspaces", wsId, "squads"],
+ queryFn: async () => [],
+ }),
+}));
+
+import { EntityMentionName } from "./mention-name";
+
+function renderWithCache(ui: ReactNode, seed: (qc: QueryClient) => void = () => {}) {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ seed(qc);
+ return render({ui});
+}
+
+describe("EntityMentionName", () => {
+ it("renders the cached agent name when the UUID resolves", () => {
+ renderWithCache(
+ ,
+ (qc) =>
+ qc.setQueryData(["workspaces", "ws-test", "agents"], [
+ { id: "agent-real", name: "RealAgent" },
+ ]),
+ );
+
+ expect(screen.getByText("@RealAgent")).toBeInTheDocument();
+ expect(screen.queryByText("@FakeLabel")).not.toBeInTheDocument();
+ });
+
+ it("falls back to the markdown label when the agent is not cached", () => {
+ renderWithCache(
+ ,
+ );
+
+ expect(screen.getByText("@Ghost")).toBeInTheDocument();
+ });
+
+ it("looks up members by user_id rather than member row id", () => {
+ renderWithCache(
+ ,
+ (qc) =>
+ qc.setQueryData(["workspaces", "ws-test", "members"], [
+ { id: "member-row-1", user_id: "user-7", name: "Alice" },
+ ]),
+ );
+
+ expect(screen.getByText("@Alice")).toBeInTheDocument();
+ });
+
+ it("renders the cached squad name when the UUID resolves", () => {
+ renderWithCache(
+ ,
+ (qc) =>
+ qc.setQueryData(["workspaces", "ws-test", "squads"], [
+ { id: "squad-1", name: "RealSquad" },
+ ]),
+ );
+
+ expect(screen.getByText("@RealSquad")).toBeInTheDocument();
+ });
+
+ it("normalizes a fallback label that already starts with @", () => {
+ renderWithCache(
+ ,
+ );
+
+ // Should render exactly "@Foo", not "@@Foo".
+ expect(screen.getByText("@Foo")).toBeInTheDocument();
+ });
+
+ it("adds an @ prefix when the fallback label is missing one", () => {
+ renderWithCache(
+ ,
+ );
+
+ expect(screen.getByText("@Bare")).toBeInTheDocument();
+ });
+});
diff --git a/packages/views/editor/mention-name.tsx b/packages/views/editor/mention-name.tsx
new file mode 100644
index 0000000000..ced96aa818
--- /dev/null
+++ b/packages/views/editor/mention-name.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { useWorkspaceId } from "@multica/core/hooks";
+import {
+ agentListOptions,
+ memberListOptions,
+ squadListOptions,
+} from "@multica/core/workspace/queries";
+
+export type EntityMentionType = "agent" | "member" | "squad";
+
+interface Props {
+ type: EntityMentionType;
+ /** The UUID embedded in the mention link (agent.id, user.id, or squad.id). */
+ id: string;
+ /** Markdown label text, with or without leading @. Shown only when the
+ * entity cannot be resolved against the workspace cache. */
+ fallbackLabel: string;
+}
+
+/**
+ * Renders the canonical entity name for an @-mention, looked up by UUID in
+ * the workspace's TanStack Query cache. The markdown label (the text inside
+ * `[...]`) is treated as a hint, not as truth — only the UUID determines
+ * which entity is shown.
+ *
+ * This is the display-side defense for label/UUID mismatch. The backend's
+ * mention.CanonicalizeMentions rewrites labels on write, so under normal
+ * conditions the label and the resolved name already agree; this component
+ * keeps comments honest for rows that predate that defense and for any
+ * future write path that bypasses it.
+ */
+export function EntityMentionName({ type, id, fallbackLabel }: Props) {
+ const wsId = useWorkspaceId();
+
+ // Each useQuery is gated by `enabled` so we only subscribe to the list
+ // that actually matters for this mention type — no fetches kicked off
+ // for the other two.
+ const { data: agents } = useQuery({
+ ...agentListOptions(wsId),
+ enabled: type === "agent",
+ });
+ const { data: members } = useQuery({
+ ...memberListOptions(wsId),
+ enabled: type === "member",
+ });
+ const { data: squads } = useQuery({
+ ...squadListOptions(wsId),
+ enabled: type === "squad",
+ });
+
+ let resolved: string | undefined;
+ if (type === "agent") {
+ resolved = agents?.find((a) => a.id === id)?.name;
+ } else if (type === "member") {
+ // Mention UUIDs for members are user IDs (see backend formatMention and
+ // CanonicalizeMentions' GetUser lookup), not workspace_member row IDs.
+ resolved = members?.find((m) => m.user_id === id)?.name;
+ } else {
+ resolved = squads?.find((s) => s.id === id)?.name;
+ }
+
+ const visible = resolved ?? stripLeadingAt(fallbackLabel);
+ return <>@{visible}>;
+}
+
+function stripLeadingAt(label: string): string {
+ return label.startsWith("@") ? label.slice(1) : label;
+}
diff --git a/packages/views/editor/readonly-content.test.tsx b/packages/views/editor/readonly-content.test.tsx
index 9e30b60859..cd9a16c82b 100644
--- a/packages/views/editor/readonly-content.test.tsx
+++ b/packages/views/editor/readonly-content.test.tsx
@@ -20,6 +20,25 @@ vi.mock("@multica/core/paths", () => ({
useWorkspaceSlug: () => "test",
}));
+vi.mock("@multica/core/hooks", () => ({
+ useWorkspaceId: () => "ws-test",
+}));
+
+vi.mock("@multica/core/workspace/queries", () => ({
+ agentListOptions: (wsId: string) => ({
+ queryKey: ["workspaces", wsId, "agents"],
+ queryFn: async () => [],
+ }),
+ memberListOptions: (wsId: string) => ({
+ queryKey: ["workspaces", wsId, "members"],
+ queryFn: async () => [],
+ }),
+ squadListOptions: (wsId: string) => ({
+ queryKey: ["workspaces", wsId, "squads"],
+ queryFn: async () => [],
+ }),
+}));
+
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: vi.fn(), openInNewTab: vi.fn() }),
}));
@@ -309,3 +328,55 @@ describe("ReadonlyContent file-card → AttachmentBlock HTML routing", () => {
expect(queryByText("report.html")).toBeNull();
});
});
+
+describe("ReadonlyContent mention rendering", () => {
+ // Pins the display-side defense for the label/UUID mismatch bug: even
+ // when a comment's markdown spells the agent's name wrong, the rendered
+ // text must match the entity the UUID actually resolves to.
+ it("renders the canonical agent name from cache, not the markdown label", () => {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ qc.setQueryData(["workspaces", "ws-test", "agents"], [
+ { id: "agent-real-uuid", name: "RealAgent" },
+ ]);
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ const mention = container.querySelector(".mention");
+ expect(mention).not.toBeNull();
+ expect(mention?.textContent).toBe("@RealAgent");
+ expect(container.textContent).not.toContain("WrongLabel");
+ });
+
+ it("falls back to the markdown label when the agent is not in cache", () => {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ const mention = container.querySelector(".mention");
+ expect(mention?.textContent).toBe("@Ghost");
+ });
+
+ it("resolves squad mentions through the squad list cache", () => {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ qc.setQueryData(["workspaces", "ws-test", "squads"], [
+ { id: "squad-uuid", name: "Coordinators" },
+ ]);
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ const mention = container.querySelector(".mention");
+ expect(mention?.textContent).toBe("@Coordinators");
+ });
+});
diff --git a/packages/views/editor/readonly-content.tsx b/packages/views/editor/readonly-content.tsx
index b7e89880a3..074749add3 100644
--- a/packages/views/editor/readonly-content.tsx
+++ b/packages/views/editor/readonly-content.tsx
@@ -38,6 +38,7 @@ import { useNavigation } from "../navigation";
import { useT } from "../i18n";
import { openExternal } from "../platform";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
+import { EntityMentionName, type EntityMentionType } from "./mention-name";
import { ImageLightbox } from "./extensions/image-view";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { openLink, isMentionHref } from "./utils/link-handler";
@@ -145,17 +146,36 @@ function ReadonlyLink({
const slug = useWorkspaceSlug();
if (isMentionHref(href)) {
- const match = href.match(/^mention:\/\/(member|agent|issue|all)\/(.+)$/);
+ const match = href.match(/^mention:\/\/(member|agent|squad|issue|all)\/(.+)$/);
+ const label =
+ typeof children === "string"
+ ? children
+ : Array.isArray(children)
+ ? children.join("")
+ : undefined;
if (match?.[1] === "issue" && match[2]) {
- const label =
- typeof children === "string"
- ? children
- : Array.isArray(children)
- ? children.join("")
- : undefined;
return ;
}
- // Member / agent / all mentions
+ // For agent / member / squad, look up the entity by UUID and render its
+ // canonical name. The markdown label is treated as a fallback hint only.
+ // This catches label/UUID mismatch ("[@A](mention://agent/)") so
+ // the rendered comment always names the entity actually addressed.
+ if (
+ match &&
+ (match[1] === "agent" || match[1] === "member" || match[1] === "squad") &&
+ match[2]
+ ) {
+ return (
+
+
+
+ );
+ }
+ // @all and any other shape: keep the literal markdown text.
return {children};
}