From 2288ea0c7ac9b79925d9b530ae6fe9270ba4600b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=BA?= Date: Mon, 18 May 2026 09:21:52 -0400 Subject: [PATCH] fix(editor): render mention name from UUID, not markdown label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend canonicalization (prior commit) rewrites mention labels on write, but rows that predate it — and any future write path that bypasses it — can still carry a label that disagrees with the UUID. Render the resolved entity's current name from the workspace cache so the displayed @mention always names who is actually addressed. EntityMentionName looks up agent / member / squad by UUID against the existing list queries (agentListOptions, memberListOptions, squadListOptions). Falls back to the markdown label only when the entity is not in cache (deleted, cross-workspace, or cold start). ReadonlyLink in readonly-content.tsx routes agent/member/squad mentions through it; the regex also gains `squad` (previously missing). @all and unknown shapes keep their literal label. Co-Authored-By: Claude Opus 4.7 --- packages/views/editor/mention-name.test.tsx | 95 +++++++++++++++++++ packages/views/editor/mention-name.tsx | 70 ++++++++++++++ .../views/editor/readonly-content.test.tsx | 71 ++++++++++++++ packages/views/editor/readonly-content.tsx | 36 +++++-- 4 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 packages/views/editor/mention-name.test.tsx create mode 100644 packages/views/editor/mention-name.tsx 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}; }