mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-14 15:20:07 +02:00
* feat(navigation): unify internal link click semantics (resolver + board/content-link pilots) One shared resolveClickIntent() now defines the click behavior table (plain = in place, cmd = background tab, cmd+shift = foreground tab, middle = background). AppLink consumes it; openLink() threads the intent through multica:navigate so content links in markdown and the editor honor modifiers on all three platform listeners. Content links now navigate in place on plain click (desktop previously forced a new foreground tab). Editor mentions reuse the readonly mention cards instead of a hand-rolled anchor. The "open issue links in new tab" preference is removed (store, settings row, four locales). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(navigation): restore mention click shielding and type the content-link event Review follow-ups: editor mention chips regain the stopPropagation shield the hand-rolled handlers had (parity with the readonly wrappers), and the desktop shell's multica:navigate listener types its event detail like the other two listeners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(navigation): complete the click-semantics unification across all surfaces Applies the shared click-intent contract everywhere the spec covers: - AppLink: caller onClick now runs before every navigation path and its preventDefault cancels navigation (fixes the sidebar pin drag guard, enables guarded links). - useRowLink and a new useIntentNavigate execute intents for row and callback surfaces; DataTable forwards the mouse event and middle clicks; table view navigates in place with modifier support. - Chat thread rows, inbox rows, search palette results, actor avatars, and editor links (middle click) honor modifiers. - "Open in new tab" added to inbox context menu and every entity row menu (projects, agents, skills, squads, autopilots, runtimes). - ~20 push-on-click buttons converted to AppLink; agent-create back affordances use useBackOrReplace. - Desktop: Cmd+[/], Cmd+arrows, mouse side buttons bound to per-tab history; Windows app-command forwarding; middle click closes tabs. - Web: scroll restoration provider capturing data-tab-scroll-root offsets keyed by pathname, served through the shared pull protocol. - Sub-issues section collapse moved to an in-memory keyed store so it survives in-session back navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(navigation): address review findings on the unification pass - Mouse side buttons: single cross-platform source (renderer mouseup); main process no longer forwards app-command, which double-navigated on Windows/Linux. Bindings hook moved to use-tab-history with tests. - useIntentNavigate requires a NavigationProvider again (no silent test-shaped fallback); inbox tests mount real providers and assert the new modifier-click and context-menu behavior. - Web scroll restoration: writes are suppressed briefly after a memento is served, so clamped restore scrolls can't overwrite or delete it. - Create-issue dialogs keep the modal open when "Customize fields" is modifier-clicked into a background tab. - Entity row menus share one "Open in new tab" shape (adapter call); DataTable click path honors defaultPrevented; ActorAvatar reuses useIntentNavigate instead of a fourth copy of the intent switch. - Search palette: behavioral tests for cmd+click, cmd+Enter, and stale-intent reset; navigation mocks repointed at the context module so the real hooks stay under test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(navigation): review blockers — tab insertion position and auxclick cell shielding - openTab inserts the new tab immediately right of the opener (the active tab), per MUL-5860 and browser convention, capped at the pinned-first boundary. The explicit "+" button (addTab) still appends; a dedupe hit focuses without reordering. Regression tests cover all four shapes. - Issue table interactive cells (pickers, checkboxes, expand/rename/ create-sub-issue buttons, in-rename capture guard) now stop auxclick the same way they stop click, so middle-clicking a control no longer bubbles into a background-tab row open. Test asserts controls are inert and row dead space still opens. - Cross-workspace open keeping focus-follows is documented at the adapter as a deliberate product exception (MUL-5860): a background tab in a non-visible workspace group would give zero feedback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
264 lines
9.6 KiB
TypeScript
264 lines
9.6 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"]);
|
|
});
|
|
|
|
it("defaults the disposition to push and carries an explicit click intent through the event", () => {
|
|
openLink("/acme/issues/MUL-1", "acme", APP_ORIGIN);
|
|
openLink("/acme/issues/MUL-2", "acme", APP_ORIGIN, "background-tab");
|
|
const details = dispatched.map(
|
|
(e) => (e as CustomEvent<{ path: string; disposition: string }>).detail,
|
|
);
|
|
expect(details).toEqual([
|
|
{ path: "/acme/issues/MUL-1", disposition: "push" },
|
|
{ path: "/acme/issues/MUL-2", disposition: "background-tab" },
|
|
]);
|
|
});
|
|
|
|
it("ignores the intent for an external URL — it always hands off to the browser", () => {
|
|
openLink("https://github.com/a/b", "acme", APP_ORIGIN, "foreground-tab");
|
|
expect(dispatched).toHaveLength(0);
|
|
expect(openSpy).toHaveBeenCalledWith(
|
|
"https://github.com/a/b",
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|