feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499)

A project has no `MUL-123`-style identifier — only a UUID and a free-text
title — so there is nothing for the bare-identifier autolink preprocessor to
detect, and the link copied out of the app is how people actually reference
one. It rendered as a raw URL.

RichLink now unfurls a bare in-app entity URL into the same chip the
`mention://project/<uuid>` form already produces (issue URLs go through the
same path for symmetry). Render-only: stored markdown is untouched, and the
editable Tiptap path is deliberately unaffected.

Three guards, each load-bearing: the link must be bare (an authored label is
never discarded), same-workspace (a chip resolves its title in the current
workspace only), and address exactly one entity page by UUID with no query or
fragment.

Also:
- mobile: tapping a `mention://project/` link navigated nowhere despite the
  `project/[id]` route existing — it now pushes the project detail.
- agents had no documented way to emit a clickable project reference: add the
  link form to the runtime brief's Mentions section and to the projects skill,
  and record in the mentioning skill why `project` sits outside `MentionRe`
  (render-only, enqueues nothing).

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Lambda
2026-07-29 21:53:37 +08:00
parent 9e3b661d44
commit d7231d34d7
12 changed files with 426 additions and 6 deletions

View File

@@ -142,6 +142,7 @@ export function Markdown({
// return without falling through to Linking.
//
// mention://issue/<uuid> → navigate to that issue detail
// mention://project/<uuid> → navigate to that project detail
// mention://member/<uuid> → no-op (no member profile screen yet)
// mention://agent/<uuid> → no-op (no agent profile screen yet)
// mention://squad/<uuid> → no-op (no squad profile screen yet)
@@ -153,8 +154,12 @@ export function Markdown({
if (slash < 0) return;
const type = rest.slice(0, slash);
const id = rest.slice(slash + 1);
if (type === "issue" && id && wsSlug) {
router.push(`/${wsSlug}/issue/${id}`);
if (id && wsSlug) {
// Route segments are singular on mobile (`/issue/`, `/project/`)
// while the mention type and web's route are the same word — keep
// the mapping explicit so a new type can't silently no-op.
if (type === "issue") router.push(`/${wsSlug}/issue/${id}`);
else if (type === "project") router.push(`/${wsSlug}/project/${id}`);
}
return;
}

View File

@@ -73,7 +73,10 @@ vi.mock("../editor/link-hover-card", () => ({
LinkHoverCard: () => null,
}));
vi.mock("../editor/utils/link-handler", () => ({
// Partial: only navigation is stubbed. The pure URL predicates stay real so
// the sanitize contract is asserted against the renderer's real dispatch.
vi.mock("../editor/utils/link-handler", async (importOriginal) => ({
...(await importOriginal<typeof import("../editor/utils/link-handler")>()),
openLink: vi.fn(),
isMentionHref: (href?: string) => Boolean(href?.startsWith("mention://")),
}));

View File

@@ -60,7 +60,10 @@ vi.mock("./link-hover-card", () => ({
LinkHoverCard: () => null,
}));
vi.mock("./utils/link-handler", () => ({
// Partial: only navigation is stubbed. The pure URL predicates stay real so
// these autolink fixtures assert the renderer's actual link/chip dispatch.
vi.mock("./utils/link-handler", async (importOriginal) => ({
...(await importOriginal<typeof import("./utils/link-handler")>()),
openLink: vi.fn(),
isMentionHref: (href?: string) => Boolean(href?.startsWith("mention://")),
}));

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { openLink, toInternalAppPath } from "./link-handler";
import {
openLink,
parseWorkspaceEntityLink,
toInternalAppPath,
} from "./link-handler";
const APP_ORIGIN = "https://app.multica.ai";
@@ -107,3 +111,87 @@ describe("openLink", () => {
expect(navigatedPaths()).toEqual(["/other/issues/MUL-1"]);
});
});
describe("parseWorkspaceEntityLink", () => {
const PROJECT_ID = "8f14e45f-ceea-4d0e-a1a2-9b1c0d3e4f5a";
const ISSUE_ID = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed";
it("parses an absolute project URL on the app origin", () => {
expect(
parseWorkspaceEntityLink(
`${APP_ORIGIN}/acme/projects/${PROJECT_ID}`,
APP_ORIGIN,
),
).toEqual({ kind: "project", id: PROJECT_ID, slug: "acme" });
});
it("parses an absolute issue URL on the app origin", () => {
expect(
parseWorkspaceEntityLink(`${APP_ORIGIN}/acme/issues/${ISSUE_ID}`, APP_ORIGIN),
).toEqual({ kind: "issue", id: ISSUE_ID, slug: "acme" });
});
it("parses a site-relative path without needing an app origin", () => {
expect(parseWorkspaceEntityLink(`/acme/projects/${PROJECT_ID}`)).toEqual({
kind: "project",
id: PROJECT_ID,
slug: "acme",
});
});
it("reports a null slug for the slugless legacy form", () => {
expect(parseWorkspaceEntityLink(`/projects/${PROJECT_ID}`)).toEqual({
kind: "project",
id: PROJECT_ID,
slug: null,
});
});
it("returns null for another origin", () => {
expect(
parseWorkspaceEntityLink(
`https://evil.example/acme/projects/${PROJECT_ID}`,
APP_ORIGIN,
),
).toBeNull();
});
it("returns null for a list page", () => {
expect(parseWorkspaceEntityLink("/acme/projects")).toBeNull();
});
it("returns null for a deeper route under the entity", () => {
expect(
parseWorkspaceEntityLink(`/acme/projects/${PROJECT_ID}/settings`),
).toBeNull();
});
it("returns null for an entity route this parser has no chip for", () => {
expect(parseWorkspaceEntityLink(`/acme/agents/${PROJECT_ID}`)).toBeNull();
});
// A query string or fragment addresses something narrower than the entity
// page, and a chip cannot carry it.
it("returns null when the link carries a query string or fragment", () => {
expect(
parseWorkspaceEntityLink(`/acme/projects/${PROJECT_ID}?tab=issues`),
).toBeNull();
expect(
parseWorkspaceEntityLink(`/acme/issues/${ISSUE_ID}#comment-3`),
).toBeNull();
});
// Only the UUID form the app's own "copy link" produces qualifies; an
// identifier stays an ordinary link.
it("returns null for a non-UUID id", () => {
expect(parseWorkspaceEntityLink("/acme/issues/MUL-1")).toBeNull();
});
it("returns null when the slug position holds a reserved slug", () => {
expect(parseWorkspaceEntityLink(`/login/projects/${PROJECT_ID}`)).toBeNull();
});
it("returns null for a malformed percent-escape", () => {
expect(parseWorkspaceEntityLink("/acme/projects/%E0%A4%A")).toBeNull();
});
});

View File

@@ -89,6 +89,91 @@ export function toInternalAppPath(
return `${target.pathname}${target.search}${target.hash}`;
}
/** An in-app entity page addressed by a link — the two kinds that have a chip. */
export interface WorkspaceEntityRef {
kind: "issue" | "project";
/** Entity UUID, decoded from the path. */
id: string;
/**
* Workspace slug the link names, or `null` for the slug-less legacy form
* (`/projects/<uuid>`), which `openLink` resolves against the current
* workspace. A caller that renders workspace-scoped data MUST compare a
* non-null slug against the current one — the entity itself is only
* resolvable inside the workspace that owns it.
*/
slug: string | null;
}
const ENTITY_ROUTE_SEGMENTS: Record<string, WorkspaceEntityRef["kind"]> = {
issues: "issue",
projects: "project",
};
// Every link the app itself produces carries a UUID (`paths.issueDetail` /
// `paths.projectDetail` are called with entity ids). Requiring that shape keeps
// a hand-written `/issues/MUL-1` out: it stays an ordinary link rather than
// needing a second, identifier-shaped resolution path here.
const ENTITY_ID_RE =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
function decodeSegment(segment: string): string | null {
try {
return decodeURIComponent(segment);
} catch {
return null;
}
}
/**
* Parse a link that addresses exactly one issue or project page on this
* deployment; `null` for everything else — external URLs, list pages, deeper
* routes, and links carrying a query string or fragment.
*
* Accepts the same two forms `openLink` navigates: a site-relative path, and an
* absolute URL pointing back at this deployment's app origin.
*/
export function parseWorkspaceEntityLink(
href: string,
appOrigin?: string | null,
): WorkspaceEntityRef | null {
const path = href.startsWith("/") ? href : toInternalAppPath(href, appOrigin);
if (!path) return null;
// A query string or fragment addresses something more specific than the
// entity page (a saved filter, an anchored comment). Collapsing that to a
// plain entity reference would silently drop it.
if (path.includes("?") || path.includes("#")) return null;
const segments: string[] = [];
for (const raw of path.split("/").filter(Boolean)) {
const decoded = decodeSegment(raw);
if (decoded === null) return null;
segments.push(decoded);
}
// `/{slug}/{route}/{id}` — what every in-app "copy link" produces.
// `/{route}/{id}` — slug-less legacy content, current-workspace by
// definition since `openLink` prepends the current slug to it.
let slug: string | null;
let route: string | undefined;
let id: string | undefined;
if (segments.length === 3) {
const [first, second, third] = segments;
if (!first || isReservedSlug(first.toLowerCase())) return null;
slug = first;
route = second;
id = third;
} else if (segments.length === 2) {
slug = null;
[route, id] = segments;
} else {
return null;
}
const kind = route ? ENTITY_ROUTE_SEGMENTS[route] : undefined;
if (!kind || !id || !ENTITY_ID_RE.test(id)) return null;
return { kind, id, slug };
}
/**
* Open a link — internal paths dispatch multica:navigate, external open new tab.
*

View File

@@ -0,0 +1,157 @@
/**
* Bare in-app entity URLs render as chips (MUL-5499).
*
* A project has no `MUL-123` shorthand — only a UUID and a free-text title — so
* the link copied out of the app IS how people reference one. This fixture pins
* the three conditions that decide whether such a link becomes a chip, because
* each of them fails silently: an over-eager rule eats an author's link label,
* and a cross-workspace unfurl replaces a working link with a chip that can
* never resolve.
*
* The real RichLink / preprocessing pipeline is exercised; only the chips
* themselves are stubbed, so the assertions are about which branch the renderer
* took, not about chip internals.
*/
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { NavigationProvider } from "../navigation/context";
import type { NavigationAdapter } from "../navigation/types";
vi.mock("../issues/hooks", () => ({
useResolveIssueIdentifier: () => null,
}));
vi.mock("../i18n", async () => {
const editor = (await import("../locales/en/editor.json")).default;
return {
useT: () => ({
t: (select: (bundle: typeof editor) => string) => select(editor),
}),
useTimeAgo: () => "just now",
};
});
vi.mock("@multica/core/api", () => ({
api: { getAttachmentTextContent: vi.fn() },
PreviewTooLargeError: class extends Error {},
PreviewUnsupportedError: class extends Error {},
}));
// Partial mock: only the workspace-context hooks are stubbed. The pure path
// helpers (isReservedSlug / isGlobalPath) stay real — the URL parser under test
// depends on the same reserved-slug list the backend enforces, and stubbing it
// would make the assertions meaningless.
vi.mock("@multica/core/paths", async (importOriginal) => ({
...(await importOriginal<typeof import("@multica/core/paths")>()),
useWorkspacePaths: () => ({
issueDetail: (id: string) => `/acme/issues/${id}`,
projectDetail: (id: string) => `/acme/projects/${id}`,
}),
useWorkspaceSlug: () => "acme",
}));
vi.mock("../issues/components/issue-mention-card", () => ({
IssueMentionCard: ({ issueId }: { issueId: string }) => (
<span data-testid="issue-chip">{issueId}</span>
),
}));
vi.mock("../projects/components/project-chip", () => ({
ProjectChip: ({ projectId }: { projectId: string }) => (
<span data-testid="project-chip">{projectId}</span>
),
}));
vi.mock("../editor/link-hover-card", () => ({
useLinkHover: () => ({}),
LinkHoverCard: () => null,
}));
import { RichContent } from "./rich-content";
const APP_ORIGIN = "https://app.multica.ai";
const PROJECT_ID = "8f14e45f-ceea-4d0e-a1a2-9b1c0d3e4f5a";
const ISSUE_ID = "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed";
function adapter(): NavigationAdapter {
return {
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/",
searchParams: new URLSearchParams(),
// The real platform adapters return an absolute URL; useAppOrigin derives
// the deployment origin from it, and without that nothing is "in-app".
getShareableUrl: (p) => `${APP_ORIGIN}${p}`,
};
}
function renderContent(content: string) {
return render(
<NavigationProvider value={adapter()}>
<RichContent content={content} />
</NavigationProvider>,
);
}
describe("bare entity URLs in readonly content", () => {
it("renders a pasted project URL as a project chip linking to the project", () => {
const { container, getByTestId } = renderContent(
`Tracked under ${APP_ORIGIN}/acme/projects/${PROJECT_ID} for now.`,
);
expect(getByTestId("project-chip").textContent).toBe(PROJECT_ID);
expect(
container.querySelector(`a[href="/acme/projects/${PROJECT_ID}"]`),
).not.toBeNull();
});
it("renders a pasted issue URL as an issue chip", () => {
const { getByTestId } = renderContent(
`See ${APP_ORIGIN}/acme/issues/${ISSUE_ID} for context.`,
);
expect(getByTestId("issue-chip").textContent).toBe(ISSUE_ID);
});
it("leaves a link the author labelled alone", () => {
// Replacing this with a chip would throw away "Roadmap" — the author said
// what they wanted the link to read as.
const { container, queryByTestId } = renderContent(
`[Roadmap](${APP_ORIGIN}/acme/projects/${PROJECT_ID})`,
);
expect(queryByTestId("project-chip")).toBeNull();
const anchor = container.querySelector("a");
expect(anchor?.textContent).toBe("Roadmap");
});
it("leaves a link into another workspace as a plain link", () => {
// The chip resolves its title in the CURRENT workspace, so unfurling here
// would swap a working link for a permanently empty chip.
const { container, queryByTestId } = renderContent(
`${APP_ORIGIN}/other-ws/projects/${PROJECT_ID}`,
);
expect(queryByTestId("project-chip")).toBeNull();
expect(
container.querySelector(
`a[href="${APP_ORIGIN}/other-ws/projects/${PROJECT_ID}"]`,
),
).not.toBeNull();
});
it("leaves a project list URL as a plain link", () => {
const { queryByTestId } = renderContent(`${APP_ORIGIN}/acme/projects`);
expect(queryByTestId("project-chip")).toBeNull();
});
it("leaves an external URL as a plain link", () => {
const { queryByTestId, container } = renderContent(
`https://github.com/multica-ai/multica/pull/1`,
);
expect(queryByTestId("project-chip")).toBeNull();
expect(queryByTestId("issue-chip")).toBeNull();
expect(container.querySelector("a")).not.toBeNull();
});
});

View File

@@ -53,7 +53,12 @@ import { IssueMentionCard } from "../issues/components/issue-mention-card";
import { useResolveIssueIdentifier } from "../issues/hooks";
import { ProjectChip } from "../projects/components/project-chip";
import { useLinkHover, LinkHoverCard } from "../editor/link-hover-card";
import { openLink, isMentionHref } from "../editor/utils/link-handler";
import {
openLink,
isMentionHref,
parseWorkspaceEntityLink,
type WorkspaceEntityRef,
} from "../editor/utils/link-handler";
import { preprocessMarkdown } from "../editor/utils/preprocess";
import { highlightToHtml } from "../editor/utils/highlight-markdown";
import { AttachmentDownloadProvider } from "../editor/attachment-download-context";
@@ -161,6 +166,39 @@ function childrenToLabel(children: ReactNode): string | undefined {
return undefined;
}
/**
* Decide whether a link should render as an entity chip instead of a raw URL.
*
* Pasting a link copied out of the app is how people reference a project:
* projects carry only a UUID and a free-text title, so unlike an issue they have
* no `MUL-123` shorthand for the autolink preprocessor to detect — the URL IS
* the reference. Rendering it as the same chip the `mention://project/<uuid>`
* form produces closes that gap without inventing a new text form. Issue URLs
* go through the same path for symmetry.
*
* Three conditions, each load-bearing:
* - BARE: the visible text is the URL itself, which is what the linkify
* preprocessor emits for a pasted URL. `[路线图](…/projects/<id>)` carries a
* label the author chose, and replacing it with a chip would discard it.
* - SAME WORKSPACE: a chip resolves its title against the CURRENT workspace,
* so unfurling a link into another workspace would turn a working link into
* a permanently empty chip. A slug-less path is current-workspace already.
* - Whatever `parseWorkspaceEntityLink` enforces: a UUID id, an exact entity
* route, and no query string or fragment.
*/
function unfurlableEntityLink(
href: string,
children: ReactNode,
currentSlug: string | null,
appOrigin: string | null,
): WorkspaceEntityRef | null {
if (childrenToLabel(children) !== href) return null;
const entity = parseWorkspaceEntityLink(href, appOrigin);
if (!entity) return null;
if (entity.slug !== null && entity.slug !== currentSlug) return null;
return entity;
}
function RichLink({ href, children }: { href?: string; children?: ReactNode }) {
const slug = useWorkspaceSlug();
const appOrigin = useAppOrigin();
@@ -186,6 +224,17 @@ function RichLink({ href, children }: { href?: string; children?: ReactNode }) {
return <span className="mention">{children}</span>;
}
// A bare in-app entity URL renders as the chip its mention form would.
const entity = href
? unfurlableEntityLink(href, children, slug, appOrigin)
: null;
if (entity?.kind === "issue") {
return <IssueMentionLink issueId={entity.id} />;
}
if (entity?.kind === "project") {
return <ProjectMentionLink projectId={entity.id} />;
}
// Regular links — open directly on click. A URL pointing back at this
// deployment routes in-app rather than out to the browser.
return (

View File

@@ -566,6 +566,10 @@ func writeMentions(b *strings.Builder) {
b.WriteString("## Mentions\n\n")
b.WriteString("Mention links are **side-effecting actions**:\n\n")
b.WriteString("- `[MUL-123](mention://issue/<issue-id>)` — clickable link (no side effect)\n")
// Projects have no `MUL-123`-style identifier to autolink, so unless the
// agent writes this form (or pastes the project URL, which the reader's
// client unfurls into the same chip) a project reference stays dead text.
b.WriteString("- `[Project Name](mention://project/<project-id>)` — clickable link (no side effect)\n")
b.WriteString("- `[@Name](mention://member/<user-id>)` — **notifies a human**\n")
b.WriteString("- `[@Name](mention://agent/<agent-id>)` — **enqueues a new run for that agent**\n\n")
b.WriteString("### When NOT to use a mention link\n\n")

View File

@@ -32,6 +32,14 @@ So the link target is a real entity UUID (or `all`), never a display name. The
label between the brackets is free text — that is where the human-readable name
goes.
One `mention://` form deliberately sits OUTSIDE this parser:
`[Label](mention://project/<uuid>)`. `project` is absent from the type group
above, so the backend never parses it and it can enqueue nothing — it is a
render-only link the clients turn into a project chip. That is the whole point:
a project reference should never be able to start a run. Use it freely to point
at a project (see the multica-projects-and-resources skill); everything else in
this document is about the four types (plus `all`) the parser does recognize.
## Step 1 — look up the UUID with `--output json`
A name is not a UUID. Look the UUID up first, from the matching list command:

View File

@@ -15,6 +15,7 @@ a pointer.
| `ParseMentions` extracts and dedups `{Type, ID}` from `m[2]`/`m[3]` | `server/internal/util/mention.go:24-37` |
| `Mention.Type` doc enum = "member", "agent", "issue", or "all" (squad added in regex) | `server/internal/util/mention.go:7` |
| `HasMentionAll` reports whether any parsed mention is `all` | `server/internal/util/mention.go:40-47` |
| **`project` is NOT in the type group** — `[Label](mention://project/<uuid>)` never parses, so it can enqueue nothing. It is a render-only link the clients turn into a project chip (`RichLink` in `packages/views/rich-content/rich-content.tsx`; tap handling in `apps/mobile/lib/markdown/markdown.tsx`) | `server/internal/util/mention.go:16` |
### Parser behavior tests (pin the example shapes the skill uses)

View File

@@ -55,6 +55,22 @@ For `github_repo`, non-JSON `--ref` sets `resource_ref.ref`, the default checkou
`--start-date` / `--due-date` are optional calendar days (`YYYY-MM-DD`, like issue dates). On `project update`, pass an empty string (`--start-date ""`) to clear a date; an unset flag leaves it untouched.
## Referring to a project in a comment
A project has no `MUL-123`-style identifier, so writing its title as prose
produces dead text — there is nothing for the reader's client to autolink. Use
the mention-link form instead, with the project UUID from
`multica project list --output json`:
[Roadmap](mention://project/<project-id>)
It renders as a navigable project chip on web, desktop, and mobile. Unlike
`@agent` / `@squad`, it is a pure link: `util.MentionRe` does not even include
`project`, so it enqueues nothing and notifies nobody — the same
no-side-effect contract as an `issue` mention. Pasting the project's URL
(`<app>/<slug>/projects/<uuid>`) works too; the reader's client unfurls a bare
in-app entity URL into that same chip.
## When to add a resource
Add/update a project resource when the user asks for durable project context: "把这个 GitHub repo 绑到项目上", "以后都用这个 repo", "agent 总是拿不到这个项目的仓库", or "这个项目要在我的本地目录里跑".

View File

@@ -10,4 +10,5 @@
- `server/pkg/db/queries/project_resource.sql` is the CRUD query surface for `project_resource` rows.
- Project resources are written into `.multica/project/resources.json` for agent workdirs.
- `github_repo.resource_ref.ref` is lifted into daemon `RepoData.Ref` by `server/internal/handler/daemon.go`; `server/internal/daemon/daemon.go` stores it per task, and `server/internal/daemon/health.go` uses it as the default `/repo/checkout` ref when the checkout request does not explicitly pass one.
- Referring to a project in a comment: `[Label](mention://project/<uuid>)` is a render-only link. `util.MentionRe` (`server/internal/util/mention.go`) does NOT include `project` in its type group, so the backend never parses it and it can enqueue nothing — deliberately weaker than `agent` / `squad`. The frontend renders it as a chip in `RichLink` (`packages/views/rich-content/rich-content.tsx`) and the editor's `MentionView` (`packages/views/editor/extensions/mention-view.tsx`); mobile navigates on tap in `apps/mobile/lib/markdown/markdown.tsx`. A bare in-app project/issue URL is unfurled into the same chip by `parseWorkspaceEntityLink` (`packages/views/editor/utils/link-handler.ts`), which requires a UUID id, no query/fragment, and the current workspace slug (MUL-5499). The agent-facing line lives in the runtime brief's Mentions section (`writeMentions`, `server/internal/daemon/execenv/runtime_config_sections.go`).
- A project's `description` is injected as durable context for every task in the project. The claim handler (`server/internal/handler/daemon.go`) reads `proj.Description` onto the claim response (`ProjectDescription`, `server/internal/handler/agent.go`); the daemon carries it through `Task` (`server/internal/daemon/types.go`) and `TaskContextForEnv` (`server/internal/daemon/execenv/execenv.go`) into the brief's `## Project Context` section (`server/internal/daemon/execenv/runtime_config.go`) and into `.multica/project/resources.json` as `project_description` (`server/internal/daemon/execenv/context.go`).