mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* 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>
110 lines
4.1 KiB
TypeScript
110 lines
4.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { openLink, 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"]);
|
|
});
|
|
});
|