Files
multica/packages/views/editor/utils/link-handler.ts
Bohan Jiang 1fef98c24f fix(desktop): open in-app links in a tab instead of the browser (MUL-5208) (#5826)
* fix(desktop): open in-app links in a tab instead of the browser (MUL-5208)

A link written as an absolute URL on this deployment's own origin
(`https://<app-host>/acme/issues/1` — what "copy link" produces, and what
agents paste into chat) fell through openLink's external branch to
window.open, which Electron routes to shell.openExternal. Clicking an issue
link in Desktop chat therefore opened a browser window instead of a tab.

openLink now resolves such a URL back to its in-app path and takes the same
route a relative path does. Backend-served prefixes (/api/, /_next/) stay
external so attachment downloads keep working.

Two supporting fixes the change depends on:

- The `multica:navigate` event had no listener on web, so in-app paths in
  content were dead links there; normalizing app URLs would have extended
  that to every pasted app URL. The web platform layer now answers the event
  with a router push.
- The desktop handler opened every path inside the active workspace's tab
  group. A cross-workspace link now goes through switchWorkspace, matching
  what the navigation adapter already does for pushes.

Co-authored-by: multica-agent <github@multica.ai>

* fix(desktop): scope in-app link conversion to workspace pages, answer it in issue windows

Review follow-ups on MUL-5208.

1. The non-page exclusion list only covered /api/ and /_next/, so a
   same-origin /uploads/* link — local-storage attachments, served by the
   backend and proxied by web — was routed as an app page, opening a dead tab
   instead of the file. Replaced the deny-list with the app's own routing
   model: an absolute URL converts to an in-app path only when its first
   segment is a slug a workspace could own, which the existing reserved-slug
   list (shared with the backend) already answers. /api, /uploads, /_next,
   /favicon.ico and the pre-workspace routes all stay external without a
   second list to keep in sync.

2. A dedicated issue window derives the same app origin from its adapter but
   had no multica:navigate listener, so a same-origin link there became a
   silent no-op (it used to reach the browser). The window now answers the
   event: another issue opens in place — matching what its adapter push and
   mention chips already do — and any other app page, which this single-route
   window cannot host, goes to the browser.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <agent@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 16:50:23 +08:00

135 lines
4.8 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";
/**
* 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}`;
}
/**
* 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://");
}