Files
multica/packages/views/editor/link-hover-card.tsx
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

233 lines
7.0 KiB
TypeScript

"use client";
/**
* LinkHoverCard — floating card shown on link hover.
*
* Displays the URL with Copy and Open actions. Portaled to body
* with position:fixed to escape overflow:hidden containers.
* Shows after 300ms hover delay, hides after 150ms mouse-out
* (cancelled if mouse enters the card).
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { ExternalLink, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { copyText } from "@multica/ui/lib/clipboard";
import { useWorkspaceSlug } from "@multica/core/paths";
import { useAppOrigin } from "../navigation";
import { useT } from "../i18n";
import { openLink, isMentionHref } from "./utils/link-handler";
function truncateUrl(url: string, max = 48): string {
if (url.length <= max) return url;
try {
const u = new URL(url);
const origin = u.origin;
const rest = url.slice(origin.length);
if (rest.length <= 10) return url;
return `${origin}${rest.slice(0, max - origin.length - 1)}`;
} catch {
return `${url.slice(0, max - 1)}`;
}
}
// ---------------------------------------------------------------------------
// Hook — manages hover state with enter/leave delays
// ---------------------------------------------------------------------------
const SHOW_DELAY = 300;
const HIDE_DELAY = 150;
interface HoverState {
visible: boolean;
href: string;
anchorEl: HTMLAnchorElement | null;
}
function useLinkHover(containerRef: React.RefObject<HTMLElement | null>, disabled?: boolean) {
const [state, setState] = useState<HoverState>({ visible: false, href: "", anchorEl: null });
const showTimer = useRef(0);
const hideTimer = useRef(0);
const cardRef = useRef<HTMLDivElement>(null);
const clearTimers = useCallback(() => {
clearTimeout(showTimer.current);
clearTimeout(hideTimer.current);
}, []);
// Container mouse events — detect <a> hover
useEffect(() => {
const container = containerRef.current;
if (!container || disabled) return;
const onMouseOver = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest("a") as HTMLAnchorElement | null;
if (!link) return;
const href = link.getAttribute("href");
if (!href || isMentionHref(href)) return;
// Issue mention cards render as <a class="issue-mention"> — they
// display their own rich info, a URL hover card is redundant.
if (link.classList.contains("issue-mention")) return;
clearTimeout(hideTimer.current);
showTimer.current = window.setTimeout(() => {
setState({ visible: true, href, anchorEl: link });
}, SHOW_DELAY);
};
const onMouseOut = (e: MouseEvent) => {
const related = e.relatedTarget as HTMLElement | null;
// Don't hide if mouse moved to the hover card
if (related && cardRef.current?.contains(related)) return;
// Don't hide if mouse moved to another part of the same link
const link = (e.target as HTMLElement).closest("a");
if (link && link.contains(related)) return;
clearTimeout(showTimer.current);
hideTimer.current = window.setTimeout(() => {
setState((s) => ({ ...s, visible: false }));
}, HIDE_DELAY);
};
container.addEventListener("mouseover", onMouseOver);
container.addEventListener("mouseout", onMouseOut);
return () => {
container.removeEventListener("mouseover", onMouseOver);
container.removeEventListener("mouseout", onMouseOut);
clearTimers();
};
}, [containerRef, disabled, clearTimers]);
// Card mouse events — keep visible while hovering the card
const onCardEnter = useCallback(() => {
clearTimeout(hideTimer.current);
}, []);
const onCardLeave = useCallback(() => {
hideTimer.current = window.setTimeout(() => {
setState((s) => ({ ...s, visible: false }));
}, HIDE_DELAY);
}, []);
return { ...state, cardRef, onCardEnter, onCardLeave };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function LinkHoverCard({
visible,
href,
anchorEl,
cardRef,
onCardEnter,
onCardLeave,
}: {
visible: boolean;
href: string;
anchorEl: HTMLAnchorElement | null;
cardRef: React.RefObject<HTMLDivElement | null>;
onCardEnter: () => void;
onCardLeave: () => void;
}) {
const [pos, setPos] = useState({ top: 0, left: 0 });
const [positioned, setPositioned] = useState(false);
const slug = useWorkspaceSlug();
const appOrigin = useAppOrigin();
const { t } = useT("editor");
// Position the card when the portal div is mounted (ref callback).
// Using useEffect would race with portal rendering — the div might
// not be in the DOM yet when the effect runs.
const setCardRef = useCallback(
(node: HTMLDivElement | null) => {
(cardRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (!node || !anchorEl) {
setPositioned(false);
return;
}
computePosition(anchorEl, node, {
placement: "bottom-start",
strategy: "fixed",
middleware: [offset(4), flip(), shift({ padding: 8 })],
}).then(({ x, y }) => {
setPos({ top: y, left: x });
setPositioned(true);
});
},
[anchorEl, cardRef],
);
// Reset positioned when hidden
useEffect(() => {
if (!visible) setPositioned(false);
}, [visible]);
if (!visible || !anchorEl) return null;
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (await copyText(href)) {
toast.success(t(($) => $.link_hover.link_copied));
} else {
toast.error(t(($) => $.link_hover.copy_failed));
}
};
const handleOpen = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
openLink(href, slug, appOrigin);
};
return createPortal(
<div
ref={setCardRef}
className="link-hover-card"
style={{
position: "fixed",
top: pos.top,
left: pos.left,
zIndex: 50,
display: positioned ? undefined : "none",
}}
onMouseEnter={onCardEnter}
onMouseLeave={onCardLeave}
>
<span
className="min-w-0 flex-1 truncate text-xs text-muted-foreground px-1"
title={href}
>
{truncateUrl(href)}
</span>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleCopy}
title={t(($) => $.link_hover.copy_link)}
>
<Copy className="size-3.5" />
</Button>
<Button
size="icon-xs"
variant="ghost"
className="text-muted-foreground"
onClick={handleOpen}
title={t(($) => $.link_hover.open_link)}
>
<ExternalLink className="size-3.5" />
</Button>
</div>,
document.body,
);
}
export { useLinkHover, LinkHoverCard };