mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-06 10:50:54 +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> * 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>
242 lines
8.8 KiB
TypeScript
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();
|
|
});
|
|
});
|