mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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://")),
|
||||
}));
|
||||
|
||||
@@ -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://")),
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
157
packages/views/rich-content/entity-link-unfurl.test.tsx
Normal file
157
packages/views/rich-content/entity-link-unfurl.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 "这个项目要在我的本地目录里跑".
|
||||
|
||||
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user