Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions packages/views/editor/mention-name.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}

describe("EntityMentionName", () => {
it("renders the cached agent name when the UUID resolves", () => {
renderWithCache(
<EntityMentionName type="agent" id="agent-real" fallbackLabel="@FakeLabel" />,
(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(
<EntityMentionName type="agent" id="ghost-agent" fallbackLabel="@Ghost" />,
);

expect(screen.getByText("@Ghost")).toBeInTheDocument();
});

it("looks up members by user_id rather than member row id", () => {
renderWithCache(
<EntityMentionName type="member" id="user-7" fallbackLabel="@Stale" />,
(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(
<EntityMentionName type="squad" id="squad-1" fallbackLabel="@OldName" />,
(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(
<EntityMentionName type="agent" id="missing" fallbackLabel="@Foo" />,
);

// Should render exactly "@Foo", not "@@Foo".
expect(screen.getByText("@Foo")).toBeInTheDocument();
});

it("adds an @ prefix when the fallback label is missing one", () => {
renderWithCache(
<EntityMentionName type="agent" id="missing" fallbackLabel="Bare" />,
);

expect(screen.getByText("@Bare")).toBeInTheDocument();
});
});
70 changes: 70 additions & 0 deletions packages/views/editor/mention-name.tsx
Original file line number Diff line number Diff line change
@@ -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;
}
71 changes: 71 additions & 0 deletions packages/views/editor/readonly-content.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
}));
Expand Down Expand Up @@ -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(
<QueryClientProvider client={qc}>
<ReadonlyContent content="[@WrongLabel](mention://agent/agent-real-uuid)" />
</QueryClientProvider>,
);

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(
<QueryClientProvider client={qc}>
<ReadonlyContent content="[@Ghost](mention://agent/missing-uuid)" />
</QueryClientProvider>,
);

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(
<QueryClientProvider client={qc}>
<ReadonlyContent content="[@OldSquadName](mention://squad/squad-uuid)" />
</QueryClientProvider>,
);

const mention = container.querySelector(".mention");
expect(mention?.textContent).toBe("@Coordinators");
});
});
36 changes: 28 additions & 8 deletions packages/views/editor/readonly-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <IssueMentionLink issueId={match[2]} label={label} />;
}
// 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/<B-uuid>)") so
// the rendered comment always names the entity actually addressed.
if (
match &&
(match[1] === "agent" || match[1] === "member" || match[1] === "squad") &&
match[2]
) {
return (
<span className="mention">
<EntityMentionName
type={match[1] as EntityMentionType}
id={match[2]}
fallbackLabel={label ?? ""}
/>
</span>
);
}
// @all and any other shape: keep the literal markdown text.
return <span className="mention">{children}</span>;
}

Expand Down
Loading