diff --git a/apps/mobile/lib/markdown/markdown.tsx b/apps/mobile/lib/markdown/markdown.tsx index 3fb980480d..258febdf91 100644 --- a/apps/mobile/lib/markdown/markdown.tsx +++ b/apps/mobile/lib/markdown/markdown.tsx @@ -142,6 +142,7 @@ export function Markdown({ // return without falling through to Linking. // // mention://issue/ → navigate to that issue detail + // mention://project/ → navigate to that project detail // mention://member/ → no-op (no member profile screen yet) // mention://agent/ → no-op (no agent profile screen yet) // mention://squad/ → 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; } diff --git a/packages/views/common/rich-content-sanitize-contract.test.tsx b/packages/views/common/rich-content-sanitize-contract.test.tsx index 5a715697b8..801a6d42a5 100644 --- a/packages/views/common/rich-content-sanitize-contract.test.tsx +++ b/packages/views/common/rich-content-sanitize-contract.test.tsx @@ -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()), openLink: vi.fn(), isMentionHref: (href?: string) => Boolean(href?.startsWith("mention://")), })); diff --git a/packages/views/editor/readonly-content.test.tsx b/packages/views/editor/readonly-content.test.tsx index dfef34be2e..46505af892 100644 --- a/packages/views/editor/readonly-content.test.tsx +++ b/packages/views/editor/readonly-content.test.tsx @@ -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()), openLink: vi.fn(), isMentionHref: (href?: string) => Boolean(href?.startsWith("mention://")), })); diff --git a/packages/views/editor/utils/link-handler.test.ts b/packages/views/editor/utils/link-handler.test.ts index c559f9f587..d416b2ff7a 100644 --- a/packages/views/editor/utils/link-handler.test.ts +++ b/packages/views/editor/utils/link-handler.test.ts @@ -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(); + }); +}); diff --git a/packages/views/editor/utils/link-handler.ts b/packages/views/editor/utils/link-handler.ts index 0fb42a1a4d..219fc3ab6e 100644 --- a/packages/views/editor/utils/link-handler.ts +++ b/packages/views/editor/utils/link-handler.ts @@ -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/`), 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 = { + 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. * diff --git a/packages/views/rich-content/entity-link-unfurl.test.tsx b/packages/views/rich-content/entity-link-unfurl.test.tsx new file mode 100644 index 0000000000..f312c76be1 --- /dev/null +++ b/packages/views/rich-content/entity-link-unfurl.test.tsx @@ -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()), + 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 }) => ( + {issueId} + ), +})); + +vi.mock("../projects/components/project-chip", () => ({ + ProjectChip: ({ projectId }: { projectId: string }) => ( + {projectId} + ), +})); + +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( + + + , + ); +} + +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(); + }); +}); diff --git a/packages/views/rich-content/rich-content.tsx b/packages/views/rich-content/rich-content.tsx index 292c6bf55b..b6bcadb33a 100644 --- a/packages/views/rich-content/rich-content.tsx +++ b/packages/views/rich-content/rich-content.tsx @@ -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/` + * 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/)` 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 {children}; } + // 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 ; + } + if (entity?.kind === "project") { + return ; + } + // Regular links — open directly on click. A URL pointing back at this // deployment routes in-app rather than out to the browser. return ( diff --git a/server/internal/daemon/execenv/runtime_config_sections.go b/server/internal/daemon/execenv/runtime_config_sections.go index 5dfd4f8b5d..a4d081838b 100644 --- a/server/internal/daemon/execenv/runtime_config_sections.go +++ b/server/internal/daemon/execenv/runtime_config_sections.go @@ -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/)` — 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/)` — clickable link (no side effect)\n") b.WriteString("- `[@Name](mention://member/)` — **notifies a human**\n") b.WriteString("- `[@Name](mention://agent/)` — **enqueues a new run for that agent**\n\n") b.WriteString("### When NOT to use a mention link\n\n") diff --git a/server/internal/service/builtin_skills/multica-mentioning/SKILL.md b/server/internal/service/builtin_skills/multica-mentioning/SKILL.md index 0ffa95232f..91455d4212 100644 --- a/server/internal/service/builtin_skills/multica-mentioning/SKILL.md +++ b/server/internal/service/builtin_skills/multica-mentioning/SKILL.md @@ -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/)`. `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: diff --git a/server/internal/service/builtin_skills/multica-mentioning/references/mentioning-source-map.md b/server/internal/service/builtin_skills/multica-mentioning/references/mentioning-source-map.md index e11f82b52b..d51ee23ced 100644 --- a/server/internal/service/builtin_skills/multica-mentioning/references/mentioning-source-map.md +++ b/server/internal/service/builtin_skills/multica-mentioning/references/mentioning-source-map.md @@ -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/)` 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) diff --git a/server/internal/service/builtin_skills/multica-projects-and-resources/SKILL.md b/server/internal/service/builtin_skills/multica-projects-and-resources/SKILL.md index 3c5e274929..d3c8ba63fa 100644 --- a/server/internal/service/builtin_skills/multica-projects-and-resources/SKILL.md +++ b/server/internal/service/builtin_skills/multica-projects-and-resources/SKILL.md @@ -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/) + +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 +(`//projects/`) 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 "这个项目要在我的本地目录里跑". diff --git a/server/internal/service/builtin_skills/multica-projects-and-resources/references/projects-and-resources-source-map.md b/server/internal/service/builtin_skills/multica-projects-and-resources/references/projects-and-resources-source-map.md index b488bfbdf4..f8225acfaf 100644 --- a/server/internal/service/builtin_skills/multica-projects-and-resources/references/projects-and-resources-source-map.md +++ b/server/internal/service/builtin_skills/multica-projects-and-resources/references/projects-and-resources-source-map.md @@ -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/)` 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`).