Files
multica/packages/views/editor/utils/link-handler.test.ts
Jiayuan Zhang d4ae220cc1 feat(rich-content): render bare in-app project/issue URLs as chips (MUL-5499) (#6141)
* 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>

* fix(rich-content): unfurl issue URLs in identifier form

The unfurl required a UUID id, on the stated grounds that "every link the
app itself produces carries a UUID". That holds for a project but not for
an issue: `copyLink` and `openInNewTab` both build
`paths.issueDetail(issueIdentifier || issueId)`, and the issue route
rewrites a UUID URL back to the identifier — so `MUL-123` is the shape a
user actually copies, out of the app or out of the address bar. The issue
half of the feature could not fire on the links people paste, while bare
`MUL-123` prose did become a chip: the fuller reference lost to the
shorter one.

`parseWorkspaceEntityLink` now accepts an issue identifier as well as a
UUID. A project still requires a UUID — it has no shorthand, so an
identifier-shaped id under /projects/ addresses nothing.

An identifier needs a lookup, which means it can miss, and the miss has to
differ by entry point. `AutolinkedIssueMentionLink` degraded to plain text,
which is right for autolinked prose and wrong for a URL: the author wrote a
link, and an issue this workspace cannot see must not cost them the only
pointer to it. The fallback is now a prop — plain text for the autolink
path, the original anchor for a URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(editor): stop drawing link chrome over mention chips

A mention chip already carries its own affordance — border, icon, hover
background — so the generic `.rich-text-editor a` color and underline draw a
second, competing one straight through the card. `.issue-mention` reset it;
`.project-mention` never did, so project chips shipped with a brand-coloured
underline through them. The rule belongs to the chip shape rather than to one
entity, so both selectors now share it and a future chip is one line.

The hover card had the same gap: it skipped `.issue-mention` only, so hovering
a project chip opened a URL card offering to copy `/{slug}/projects/{uuid}` —
an in-app path, not the shareable link that wording implies.

Both are pre-existing, but a bare project URL now renders as a chip, so what
used to surface on hand-written mentions alone shows up on ordinary pasted
links.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(rich-content): decide in-app by resolving the URL, not by its prefix

`href.startsWith("/")` was standing in for "this deployment". It is not: a
browser reads `//other.example/x` and `/\other.example/x` as another host and
goes there, and both start with a slash. The parser skipped the origin check
for exactly the hrefs that most needed it.

Nothing shipped from this: an unfurled chip links to
`paths.projectDetail(uuid)`, so the href it was parsed from is discarded and a
misparse could not send anyone anywhere. The prefix test was still the wrong
instrument. Adding `&& !startsWith("//")` would have looked like a fix while
leaving the backslash spelling through — the gap is the technique, not the
case, so this resolves the href against the app origin with `URL` and compares
`origin`, which is one comparison for every spelling and for the schemes
(`javascript:`, `data:`) whose opaque origin can never match.

Relative and absolute now take the same path, so the slugless legacy form
parses identically whether or not it carries the origin — previously the
absolute spelling was rejected by a reserved-slug test meant for workspace
slugs, and the two disagreed.

`openLink` still tests the prefix, and its result IS navigated. That is a live
issue, older than this feature and wider than it; it needs its own change
rather than a quiet ride here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skills): state what mobile actually does with a project mention

The projects skill told agents a `mention://project/<uuid>` link "renders as a
navigable project chip on web, desktop, and mobile", and that a pasted project
URL is unfurled into that same chip by "the reader's client". Neither holds on
mobile: `apps/mobile/lib/markdown/markdown.tsx` renders the default enriched
link and only routes the tap, and a bare URL still goes to `Linking.openURL`,
which leaves the app.

These files enter agent context and read as product contract, so an agent
choosing between a mention link and a pasted URL was choosing on false
information — and the URL is the option that strands a mobile reader in a
browser. Both skills and both source maps now say chip on web/desktop, ordinary
link that opens the project on tap on mobile, and unfurling as web/desktop only.

The projects skill also now states the preference outright rather than
presenting the two forms as equivalent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(views): give a project mention the component an issue mention has

`IssueMentionCard` owns "chip inside a link" for issues; the project equivalent
lived inline in the readonly renderer, so nothing named the pairing and nothing
held the rules that come with being a link.

That cost was not hypothetical. Both gaps fixed a commit ago landed on project
mentions alone: `.project-mention` never got the CSS rule cancelling generic
link chrome, and the hover card never learned to skip it. Each was written for
`.issue-mention` at the component that owns it, and project had no such place
for the second half to be written. `ProjectMentionCard` is that place.

No behaviour change: same anchor, same href, same hover affordance, same
accessibility contract that project-mention-a11y.test.tsx pins. The "open in
new tab" preference stays out — it is scoped to issue links, and inheriting it
by symmetry would be inventing product.

Also drops `not-prose` from both cards. It has no definition anywhere in the
repo — Tailwind's typography plugin is not installed, and the class does not
appear in built CSS — so it read as protection that was not there.

The editor's `MentionView` keeps its hand-rolled anchors: it needs a
modifier-click intent hook `AppLink` does not expose, and it does the same for
issues, so the two stay symmetric there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:18:13 +08:00

242 lines
8.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
openLink,
parseWorkspaceEntityLink,
toInternalAppPath,
} from "./link-handler";
const APP_ORIGIN = "https://app.multica.ai";
function navigatedPaths(): string[] {
return dispatched.map((e) => (e as CustomEvent<{ path: string }>).detail.path);
}
let dispatched: Event[] = [];
let openSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
dispatched = [];
vi.spyOn(window, "dispatchEvent").mockImplementation((e: Event) => {
dispatched.push(e);
return true;
});
openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("toInternalAppPath", () => {
it("returns the path (with search and hash) for a URL on the app origin", () => {
expect(
toInternalAppPath(`${APP_ORIGIN}/acme/issues/MUL-1?tab=a#c`, APP_ORIGIN),
).toBe("/acme/issues/MUL-1?tab=a#c");
});
it("returns null for another origin", () => {
expect(toInternalAppPath("https://github.com/a/b/pull/1", APP_ORIGIN)).toBeNull();
});
it("returns null when the platform exposes no app origin", () => {
expect(toInternalAppPath(`${APP_ORIGIN}/acme/issues/1`, null)).toBeNull();
});
it("keeps backend-served paths external so downloads and assets still work", () => {
// Every one of these first segments is a reserved slug, which is exactly
// why the reserved list — not a hand-kept deny-list — decides this.
expect(
toInternalAppPath(`${APP_ORIGIN}/api/attachments/abc/download`, APP_ORIGIN),
).toBeNull();
expect(
toInternalAppPath(`${APP_ORIGIN}/uploads/2026/07/report.pdf`, APP_ORIGIN),
).toBeNull();
expect(toInternalAppPath(`${APP_ORIGIN}/uploads`, APP_ORIGIN)).toBeNull();
expect(toInternalAppPath(`${APP_ORIGIN}/_next/static/x.js`, APP_ORIGIN)).toBeNull();
expect(toInternalAppPath(`${APP_ORIGIN}/favicon.ico`, APP_ORIGIN)).toBeNull();
});
it("keeps pre-workspace and root paths external — they are not workspace pages", () => {
expect(toInternalAppPath(`${APP_ORIGIN}/login`, APP_ORIGIN)).toBeNull();
expect(toInternalAppPath(`${APP_ORIGIN}/auth/callback`, APP_ORIGIN)).toBeNull();
expect(toInternalAppPath(`${APP_ORIGIN}/`, APP_ORIGIN)).toBeNull();
});
it("ignores case and percent-encoding when matching a reserved first segment", () => {
expect(toInternalAppPath(`${APP_ORIGIN}/UPLOADS/x.pdf`, APP_ORIGIN)).toBeNull();
expect(toInternalAppPath(`${APP_ORIGIN}/%75ploads/x.pdf`, APP_ORIGIN)).toBeNull();
});
it("returns null for non-http schemes and unparseable hrefs", () => {
expect(toInternalAppPath("mailto:a@b.com", APP_ORIGIN)).toBeNull();
expect(toInternalAppPath("not a url", APP_ORIGIN)).toBeNull();
});
});
describe("openLink", () => {
it("navigates in-app for a URL pointing back at this deployment (MUL-5208)", () => {
openLink(`${APP_ORIGIN}/acme/issues/MUL-1`, "acme", APP_ORIGIN);
expect(navigatedPaths()).toEqual(["/acme/issues/MUL-1"]);
expect(openSpy).not.toHaveBeenCalled();
});
it("navigates in-app for a cross-workspace app URL without rewriting the slug", () => {
openLink(`${APP_ORIGIN}/other/issues/MUL-1`, "acme", APP_ORIGIN);
expect(navigatedPaths()).toEqual(["/other/issues/MUL-1"]);
});
it("opens an external URL in a new window", () => {
openLink("https://github.com/multica-ai/multica/pull/1", "acme", APP_ORIGIN);
expect(dispatched).toHaveLength(0);
expect(openSpy).toHaveBeenCalledWith(
"https://github.com/multica-ai/multica/pull/1",
"_blank",
"noopener,noreferrer",
);
});
it("still opens an app URL externally when no app origin is known", () => {
openLink(`${APP_ORIGIN}/acme/issues/MUL-1`, "acme");
expect(dispatched).toHaveLength(0);
expect(openSpy).toHaveBeenCalled();
});
it("prefixes the current slug on a slugless workspace path", () => {
openLink("/issues/MUL-1", "acme", APP_ORIGIN);
expect(navigatedPaths()).toEqual(["/acme/issues/MUL-1"]);
});
it("leaves a path that already carries a slug alone", () => {
openLink("/other/issues/MUL-1", "acme", APP_ORIGIN);
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();
});
// A leading slash does not mean "this site". Both of these name another host
// and a browser follows them there, so the parser has to resolve the href
// rather than test its prefix.
it("returns null for a host-bearing href that still starts with a slash", () => {
expect(
parseWorkspaceEntityLink(`//evil.example/projects/${PROJECT_ID}`, APP_ORIGIN),
).toBeNull();
// Backslashes are normalised to slashes, so this names evil.example too —
// and it slips past a `//` prefix test.
expect(
parseWorkspaceEntityLink(`/\\evil.example/projects/${PROJECT_ID}`, APP_ORIGIN),
).toBeNull();
});
it("returns null for a non-http scheme", () => {
expect(parseWorkspaceEntityLink("javascript:alert(1)", APP_ORIGIN)).toBeNull();
});
// The slugless form resolves against the current workspace either way; the
// two spellings must not disagree about that.
it("treats the slugless form the same whether or not it carries the origin", () => {
expect(
parseWorkspaceEntityLink(`${APP_ORIGIN}/projects/${PROJECT_ID}`, APP_ORIGIN),
).toEqual({ kind: "project", id: PROJECT_ID, slug: null });
});
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();
});
// `copyLink` / `openInNewTab` build `paths.issueDetail(identifier || id)` and
// the issue route rewrites a UUID URL back to the identifier, so this — not
// the UUID form — is what a user actually copies out of the app.
it("parses an issue addressed by identifier", () => {
expect(parseWorkspaceEntityLink("/acme/issues/MUL-1")).toEqual({
kind: "issue",
id: "MUL-1",
slug: "acme",
});
});
// A project has no shorthand, so an identifier-shaped id under /projects/
// addresses nothing this parser could resolve.
it("returns null for an identifier-shaped project id", () => {
expect(parseWorkspaceEntityLink("/acme/projects/MUL-1")).toBeNull();
});
it("returns null for an id that is neither a UUID nor an identifier", () => {
expect(parseWorkspaceEntityLink("/acme/issues/roadmap")).toBeNull();
// Lowercase is not the identifier form — matching it would turn ordinary
// hyphenated path segments into entity references.
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();
});
});