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>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
/**
|
|
* Shared link handling utilities for the editor system.
|
|
*
|
|
* Used by content-editor (ProseMirror click handler), readonly-content
|
|
* (react-markdown link component), and link-hover-card (Open button).
|
|
*/
|
|
|
|
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
|
|
import { isIssueIdentifier } from "@multica/ui/markdown";
|
|
|
|
/**
|
|
* Top-level workspace-scoped routes. Used to detect "/{route}/..." paths that
|
|
* were authored without a workspace slug — we prepend the current slug so they
|
|
* resolve correctly under the new /{slug}/{route}/... URL shape.
|
|
*
|
|
* Why a hardcoded allowlist: the heuristic must be conservative. A path like
|
|
* "/acme/issues/abc" already has a slug (first segment "acme" isn't a known
|
|
* route), so leaving it alone is correct. A path like "/foo/bar" where "foo"
|
|
* isn't a known route is ambiguous — we don't rewrite it, treating the author
|
|
* as intentional. Only "/issues/..." style paths get auto-prefixed.
|
|
*/
|
|
const WORKSPACE_ROUTE_SEGMENTS = new Set([
|
|
"usage",
|
|
"issues",
|
|
"projects",
|
|
"autopilots",
|
|
"agents",
|
|
"chat",
|
|
"inbox",
|
|
"my-issues",
|
|
"runtimes",
|
|
"skills",
|
|
"settings",
|
|
]);
|
|
|
|
/**
|
|
* Report whether a path is a workspace-scoped app page — `/{slug}/...` where the
|
|
* first segment is a slug a workspace could actually own.
|
|
*
|
|
* The app origin also serves things the app router does not own: `/api/*`,
|
|
* `/uploads/*` (local-storage attachments, proxied by web), `/_next/*`,
|
|
* `/favicon.ico`, and the pre-workspace routes. Every one of those first
|
|
* segments is already a reserved slug, so the reserved list — the same one the
|
|
* backend enforces at workspace creation — answers this question without a
|
|
* parallel deny-list that has to be kept in sync with the backend's routes.
|
|
*/
|
|
function isWorkspaceScopedPath(pathname: string): boolean {
|
|
const first = pathname.split("/")[1] ?? "";
|
|
if (!first) return false;
|
|
let segment: string;
|
|
try {
|
|
segment = decodeURIComponent(first);
|
|
} catch {
|
|
return false;
|
|
}
|
|
return !isReservedSlug(segment.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Convert an absolute URL that points at a workspace page on this deployment's
|
|
* own app into the in-app path it addresses; `null` for anything else.
|
|
*
|
|
* An agent or a user pasting `https://<app-host>/acme/issues/123` means the same
|
|
* destination as `/acme/issues/123`. Without this, the URL reads as external and
|
|
* the desktop app hands it to the system browser instead of opening a tab
|
|
* (MUL-5208).
|
|
*
|
|
* `appOrigin` is the deployment's public app URL, which only the platform layer
|
|
* knows (web: the current origin; desktop: the connected environment's app URL).
|
|
* See `useAppOrigin()`.
|
|
*/
|
|
export function toInternalAppPath(
|
|
href: string,
|
|
appOrigin?: string | null,
|
|
): string | null {
|
|
if (!appOrigin) return null;
|
|
let target: URL;
|
|
let app: URL;
|
|
try {
|
|
target = new URL(href);
|
|
app = new URL(appOrigin);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (target.origin !== app.origin) return null;
|
|
// Opaque origins (file:, data:) compare equal to each other; only real web
|
|
// origins identify the app.
|
|
if (target.protocol !== "http:" && target.protocol !== "https:") return null;
|
|
if (!isWorkspaceScopedPath(target.pathname)) return null;
|
|
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 id, decoded from the path. A UUID for either kind, or — for an
|
|
* issue only — a bare identifier (`MUL-123`). Callers dispatch on the shape
|
|
* with `isIssueIdentifier`: an identifier still has to be resolved to a real
|
|
* issue before it can be rendered as a chip.
|
|
*/
|
|
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",
|
|
};
|
|
|
|
const UUID_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}$/;
|
|
|
|
/**
|
|
* Does this path segment address one entity of `kind`?
|
|
*
|
|
* A project is only ever addressed by UUID — it has no shorthand. An issue has
|
|
* both, and the identifier form is the one that matters most: `copyLink` and
|
|
* `openInNewTab` both build `paths.issueDetail(identifier || id)`, and the
|
|
* issue route rewrites a UUID URL back to the identifier, so `MUL-123` is what
|
|
* a user actually copies out of the app or the address bar. Accepting only the
|
|
* UUID here would leave the shape people really paste as a raw URL.
|
|
*
|
|
* Identifier-shaped ids are candidates, not hits: the caller resolves one
|
|
* against the current workspace and keeps the plain link when it misses.
|
|
*/
|
|
function isEntityId(kind: WorkspaceEntityRef["kind"], id: string): boolean {
|
|
return UUID_RE.test(id) || (kind === "issue" && isIssueIdentifier(id));
|
|
}
|
|
|
|
function decodeSegment(segment: string): string | null {
|
|
try {
|
|
return decodeURIComponent(segment);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stand-in origin for resolving a href that carries no origin of its own. The
|
|
* `.invalid` TLD is reserved as permanently unresolvable (RFC 2606), so no
|
|
* deployment can ever be served from it and no href can use it to pass as one.
|
|
*/
|
|
const RELATIVE_BASE = "https://link.invalid";
|
|
|
|
/**
|
|
* Resolve a link to the path it addresses on THIS deployment; `null` when it
|
|
* addresses anywhere else.
|
|
*
|
|
* Resolving through `URL` rather than testing the string is what makes the
|
|
* answer trustworthy. A leading slash does not mean "this site":
|
|
* `//other.example/x` and `/\other.example/x` both start with one and both name
|
|
* another host, which is where a browser goes. A parser sees the host; a prefix
|
|
* test cannot, and no amount of extra prefixes closes the gap — that is why the
|
|
* shape of this check matters more than the cases it currently rejects.
|
|
*
|
|
* With no `appOrigin` (a platform that cannot report one), only a genuinely
|
|
* relative href resolves: anything carrying a host of its own fails the
|
|
* comparison, which is the safe direction.
|
|
*/
|
|
function toSameOriginPath(
|
|
href: string,
|
|
appOrigin?: string | null,
|
|
): string | null {
|
|
const base = appOrigin || RELATIVE_BASE;
|
|
let target: URL;
|
|
let expected: URL;
|
|
try {
|
|
target = new URL(href, base);
|
|
expected = new URL(base);
|
|
} catch {
|
|
return null;
|
|
}
|
|
// Opaque origins (`javascript:`, `data:`) stringify to "null" and can never
|
|
// equal a real one, so they fall out here too.
|
|
if (target.origin !== expected.origin) return null;
|
|
return `${target.pathname}${target.search}${target.hash}`;
|
|
}
|
|
|
|
/**
|
|
* 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 a site-relative path and an absolute URL pointing back at this
|
|
* deployment's app origin. Both go through the same origin comparison, so the
|
|
* two forms cannot disagree about what counts as in-app.
|
|
*/
|
|
export function parseWorkspaceEntityLink(
|
|
href: string,
|
|
appOrigin?: string | null,
|
|
): WorkspaceEntityRef | null {
|
|
const path = toSameOriginPath(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 || !isEntityId(kind, id)) return null;
|
|
return { kind, id, slug };
|
|
}
|
|
|
|
/**
|
|
* Open a link — internal paths dispatch multica:navigate, external open new tab.
|
|
*
|
|
* If `currentSlug` is provided and `href` is a workspace-scoped path lacking a
|
|
* slug (e.g. "/issues/abc" instead of "/{slug}/issues/abc"), the slug is
|
|
* prepended. This is for legacy markdown content authored before the URL
|
|
* refactor, or future content where users forget the slug when pasting.
|
|
*
|
|
* `appOrigin` lets absolute URLs pointing back at this deployment take the same
|
|
* internal route as a relative path.
|
|
*/
|
|
export function openLink(
|
|
href: string,
|
|
currentSlug?: string | null,
|
|
appOrigin?: string | null,
|
|
): void {
|
|
const internalPath = href.startsWith("/")
|
|
? href
|
|
: toInternalAppPath(href, appOrigin);
|
|
if (internalPath) {
|
|
let path = internalPath;
|
|
if (currentSlug && !isGlobalPath(path)) {
|
|
const firstSegment = path.split("/")[1];
|
|
if (firstSegment && WORKSPACE_ROUTE_SEGMENTS.has(firstSegment)) {
|
|
// Path looks like /issues/abc (no slug) — prepend current slug.
|
|
path = `/${currentSlug}${path}`;
|
|
}
|
|
// Otherwise the first segment is either already a slug (e.g. "acme" in
|
|
// "/acme/issues") or something unknown (e.g. "/foo"). Leave it alone —
|
|
// the user wrote what they meant.
|
|
}
|
|
window.dispatchEvent(
|
|
new CustomEvent("multica:navigate", { detail: { path } }),
|
|
);
|
|
} else {
|
|
window.open(href, "_blank", "noopener,noreferrer");
|
|
}
|
|
}
|
|
|
|
/** Check if a href is a mention protocol link (should not be opened as a regular link). */
|
|
export function isMentionHref(href: string | null | undefined): href is string {
|
|
return !!href && href.startsWith("mention://");
|
|
}
|